diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index efe965428..c9e4ba2bf 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "dockerfile": "Dockerfile", "args": { // Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14 - "VARIANT": "1.27", + "VARIANT": "1.26", // Options "INSTALL_NODE": "true", "NODE_VERSION": "v24" diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e5ed36e70..534cfbd11 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,10 +1,10 @@ # These are supported funding model platforms -ko_fi: deluan github: deluan -open_collective: navidrome -liberapay: deluan patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: deluan +liberapay: deluan tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry issuehunt: # Replace with a single IssueHunt username diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml index 6cb54dbdb..b8cde4aaf 100644 --- a/.github/actions/prepare-docker/action.yml +++ b/.github/actions/prepare-docker/action.yml @@ -68,11 +68,6 @@ runs: - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v4 - with: - # Runner IPs are shared, so anonymous base image pulls get rate-limited. - buildkitd-config-inline: | - [registry."docker.io"] - mirrors = ["mirror.gcr.io"] - name: Extract metadata for Docker image id: meta diff --git a/.github/workflows/coverage-on-pr.yml b/.github/workflows/coverage-on-pr.yml deleted file mode 100644 index 03260cacc..000000000 --- a/.github/workflows/coverage-on-pr.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Report coverage on PR -on: - workflow_run: - workflows: ['Pipeline: Test, Lint, Build'] - types: [completed] -jobs: - comment: - name: Comment coverage report - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - permissions: - contents: read - actions: read - pull-requests: write - env: - COVERAGE_COMMENT: 'true' - steps: - # Only the config, from the base branch: this job holds a write token, so - # it must never check out the fork. - - name: Check out the octocov config - uses: actions/checkout@v7 - with: - sparse-checkout: .octocov.yml - sparse-checkout-cone-mode: false - persist-credentials: false - - # Into a subdirectory. A pull_request run executes the fork's own copy of - # pipeline.yml, so every file in here is attacker-controlled. - - uses: actions/download-artifact@v8 - with: - name: octocov-pr - path: untrusted - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - - name: Verify the artifact and take the coverage profile - id: pr - env: - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - number=$(head -c 20 untrusted/pr_number | tr -d '[:space:]') - case "$number" in ''|*[!0-9]*) - echo "::error::artifact pr_number is not a number"; exit 1;; - esac - sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .head.sha) - if [ "$sha" != "$HEAD_SHA" ]; then - echo "::error::artifact claims PR #$number, but its head $sha is not $HEAD_SHA"; exit 1 - fi - cp untrusted/coverage.out coverage.out - echo "number=$number" >> "$GITHUB_OUTPUT" - - - uses: k1LoW/octocov-action@v1 - env: - # A workflow_run job looks like a push to the default branch. Point - # octocov back at the pull request and at the run that produced it. - GITHUB_PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.number }} - OCTOCOV_GITHUB_REF: refs/pull/${{ steps.pr.outputs.number }}/merge - OCTOCOV_GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} - OCTOCOV_GITHUB_RUN_ID: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 80ec18e90..076f963d4 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@v9 + - uses: actions/github-script@v7 with: # This snippet is public-domain, taken from # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml @@ -34,19 +34,16 @@ jobs: } const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id}); - const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-')); - if (!downloadable.length) { + if (!artifacts.length) { return core.error(`No artifacts found`); } - const header = `Download the artifacts for this pull request:`; - let body = `${header}\n`; - for (const art of downloadable) { + let body = `Download the artifacts for this pull request:\n`; + for (const art of artifacts) { body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`; } const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number}); - // Match on the body too: octocov also comments as github-actions[bot]. - const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header)); + const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]'); if (existing_comment) { core.info(`Updating comment ${existing_comment.id}`); await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index c1ba8714c..6f858a5a7 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@v7 + - uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true @@ -32,7 +32,7 @@ jobs: - name: Show git version info run: | echo "git describe (dirty): $(git describe --dirty --always --tags)" - echo "git describe --tags --abbrev=0: $(git describe --tags --abbrev=0)" + echo "git describe --tags: $(git describe --tags `git rev-list --tags --max-count=1`)" echo "git tag: $(git tag --sort=-committerdate | head -n 1)" echo "github_ref: $GITHUB_REF" echo "github_head_sha: ${{ github.event.pull_request.head.sha }}" @@ -40,7 +40,7 @@ jobs: - name: Determine git current SHA and latest tag id: git-version run: | - GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) + GIT_TAG=$(git tag --sort=-committerdate | head -n 1) if [ -n "$GIT_TAG" ]; then if [[ "$GITHUB_REF" != refs/tags/* ]]; then GIT_TAG=${GIT_TAG}-SNAPSHOT @@ -62,22 +62,16 @@ jobs: name: Lint Go code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version-file: go.mod - # Keep CI on the same version `make lint` installs, so a clean local run - # cannot turn red in CI just because a new golangci-lint was released. - - name: Resolve golangci-lint version - id: golangci-version - run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT" - - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: ${{ steps.golangci-version.outputs.version }} + version: latest problem-matchers: true args: --timeout 2m @@ -92,23 +86,8 @@ jobs: exit 1 fi - - name: Resolve OpenAPI tool versions - id: api-tools - run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT" - - - name: Cache OpenAPI tools - uses: actions/cache@v6 - with: - path: bin - key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }} - - - name: Lint OpenAPI spec - run: make api-lint - - name: Run go generate - run: | - make api-gen - go generate ./... + run: go generate ./... - name: Verify no changes from go generate run: | git status --porcelain @@ -117,39 +96,12 @@ jobs: exit 1 fi - - name: Check for breaking OpenAPI changes - if: github.event_name == 'pull_request' - run: | - git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }} - make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }} - - validate-migrations: - name: Validate DB migrations - runs-on: ubuntu-latest - # PR-only gate is at step level: a job-level skip would propagate through - # the needs chain (actions/runner#491) and skip all release jobs on tag pushes. - steps: - - uses: actions/checkout@v7 - if: github.event_name == 'pull_request' - with: - fetch-depth: 0 - # Refresh the base branch so the check compares against its CURRENT tip, - # not the (possibly stale) commit the PR was opened against. - - name: Fetch latest base branch - if: github.event_name == 'pull_request' - run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" - - name: Validate migration ordering and naming - if: github.event_name == 'pull_request' - env: - BASE_REF: origin/${{ github.event.pull_request.base.ref }} - run: ./.github/workflows/validate-migrations.sh - go: name: Test Go code runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory - uses: actions/checkout@v7 + uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: @@ -158,10 +110,8 @@ jobs: - name: Download dependencies run: go mod download - # Name must stay unique across the workflow: octocov matches step names - # by name across every job, and waits for each match to finish. - - name: Test with coverage - run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$') + - name: Test + run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v - name: Test ndpgen run: | @@ -170,84 +120,6 @@ jobs: go build -o ndpgen . ./ndpgen --help - - name: Upload coverage profile - uses: actions/upload-artifact@v7 - with: - name: octocov-go - path: coverage.out - if-no-files-found: error - - go-plugins: - name: Test Go plugins - runs-on: ubuntu-latest - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v7 - - - uses: actions/setup-go@v6 - id: setup-go - with: - go-version-file: go.mod - - # Without this, the suite recompiles every test plugin WASM module, - # which dominates its runtime under -race. - - name: Cache the WASM compilation cache - uses: actions/cache@v6 - with: - path: plugins/testdata/.wazero-cache - key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} - restore-keys: wazero-${{ runner.os }}- - - - name: Test plugins - run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/ - - - name: Upload coverage profile - uses: actions/upload-artifact@v7 - with: - name: octocov-plugins - path: coverage.out - if-no-files-found: error - - coverage: - name: Report coverage - runs-on: ubuntu-latest - needs: [go, go-plugins] - permissions: - contents: read - actions: write - env: - COVERAGE_COMMENT: 'false' - steps: - - uses: actions/checkout@v7 - - - uses: actions/download-artifact@v8 - with: - pattern: octocov-* - - # Merge here rather than letting octocov do it: octocov reports statement - # coverage for a single profile, but switches to line counting for several. - - name: Merge coverage profiles - run: | - echo "mode: atomic" > coverage.out - awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \ - octocov-*/coverage.out | sort >> coverage.out - - - uses: k1LoW/octocov-action@v1 - - - name: Save the PR number for the comment workflow - if: github.event_name == 'pull_request' - run: echo "${{ github.event.pull_request.number }}" > pr_number - - - name: Upload the merged profile for the comment workflow - if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v7 - with: - name: octocov-pr - path: | - coverage.out - pr_number - if-no-files-found: error - go-windows: name: Test Go code (Windows) runs-on: windows-2022 @@ -255,7 +127,7 @@ jobs: FFMPEG_VERSION: "7.1" FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: @@ -273,7 +145,7 @@ jobs: - name: Cache ffmpeg id: ffmpeg-cache - uses: actions/cache@v6 + uses: actions/cache@v5 with: path: C:\ffmpeg key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 @@ -314,12 +186,12 @@ jobs: run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v - name: Test ndpgen - shell: bash + shell: pwsh run: | - cd plugins/cmd/ndpgen + cd plugins\cmd\ndpgen go test -shuffle=on -v go build -o ndpgen.exe . - ./ndpgen.exe --help + .\ndpgen.exe --help js: name: Test JS code @@ -327,7 +199,7 @@ jobs: env: NODE_OPTIONS: "--max_old_space_size=4096" steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 24 @@ -358,7 +230,7 @@ jobs: name: Lint i18n files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - run: | set -e for file in resources/i18n/*.json; do @@ -385,7 +257,7 @@ jobs: build: name: Build - needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] + needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled] strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] @@ -404,7 +276,7 @@ jobs: PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_') echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Prepare Docker Buildx uses: ./.github/actions/prepare-docker @@ -428,21 +300,6 @@ jobs: GIT_SHA=${{ env.GIT_SHA }} GIT_TAG=${{ env.GIT_TAG }} - - name: Set up QEMU for smoke test - if: env.IS_LINUX == 'true' - uses: docker/setup-qemu-action@v4 - - # The binary is static, so binfmt+qemu runs it directly on the runner. - # Catches startup crashes in cross-compiled binaries before they ship, - # e.g. the broken ifunc relocations on 32-bit arm from issue #5738. - - name: Smoke-test binary - if: env.IS_LINUX == 'true' - run: | - BIN=./output/${{ env.PLATFORM }}/navidrome - chmod +x "$BIN" - "$BIN" --help >/dev/null - echo "OK: ${{ matrix.platform }} binary starts" - - name: Upload Binaries uses: actions/upload-artifact@v7 with: @@ -493,7 +350,7 @@ jobs: env: REGISTRY_IMAGE: ghcr.io/${{ github.repository }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Download digests uses: actions/download-artifact@v8 @@ -527,7 +384,7 @@ jobs: if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' continue-on-error: true steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Download digests uses: actions/download-artifact@v8 @@ -570,7 +427,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do + for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact done @@ -580,7 +437,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: actions/download-artifact@v8 with: @@ -614,7 +471,7 @@ jobs: outputs: package_list: ${{ steps.set-package-list.outputs.package_list }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true @@ -634,7 +491,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 with: - version: '2.16.0' + version: '~> v2' args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/push-translations.yml b/.github/workflows/push-translations.yml index 546c64114..f7cf00621 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@v7 + - uses: actions/checkout@v6 with: fetch-depth: 2 diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index 5db32f3fa..8fe0b5379 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@v7 + - uses: actions/checkout@v6 - name: Get updated translations id: poeditor env: diff --git a/.github/workflows/validate-migrations.sh b/.github/workflows/validate-migrations.sh deleted file mode 100755 index 07d05c3a6..000000000 --- a/.github/workflows/validate-migrations.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -# -# Validates DB migrations added by a pull request: -# 1. Ordering - an added migration must be NEWER than the latest migration -# already on the base branch. Goose applies migrations in -# timestamp order, so an older-timestamped migration would be -# silently skipped on databases already upgraded past it. -# 2. Uniqueness - no two migration files may share a timestamp. -# 3. Naming - files must match YYYYMMDDHHMMSS_lower_snake_name.(sql|go). -# -# On failure it prints a human-readable message and, when running in GitHub -# Actions, emits an error annotation bound to the offending file so the message -# also renders inline in the PR "Files changed" tab. -# -# Compares HEAD against $BASE_REF (default origin/master). Requires full history -# (fetch-depth: 0 in CI). -# -e is intentionally omitted: the script accumulates violations into $status -# and must not exit on the first non-zero command (grep no-match, a false [[ ]] -# in an if, `is_migration || continue`). -set -uo pipefail -export LC_ALL=C - -MIGRATIONS_DIR="db/migrations" -BASE_REF="${BASE_REF:-origin/master}" -NAME_RE='^[0-9]{14}_[a-z0-9_]+\.(sql|go)$' - -status=0 - -# Log a message to stderr and mark the run as failed. -fail() { - printf '%s\n' "$1" >&2 - status=1 -} - -# Emit a GitHub Actions error annotation bound to a file, so the message renders -# inline on the offending migration in the PR "Files changed" tab. No-op outside -# CI. `%`, newline and CR are encoded as required by the workflow-command syntax -# (the `%` replacement must run first so the encodings we add aren't re-escaped). -annotate() { # $1=file $2=message - [ "${GITHUB_ACTIONS:-}" = "true" ] || return 0 - local msg="$2" - msg="${msg//'%'/%25}" - msg="${msg//$'\n'/%0A}" - msg="${msg//$'\r'/%0D}" - printf '::error file=%s,line=1::%s\n' "$1" "$msg" -} - -# Report a migration problem: log it, annotate the offending file, mark failed. -report() { # $1=file $2=message - fail "$2" - printf '\n' >&2 - annotate "$1" "$2" -} - -human_ts() { - local t="$1" - printf '%s-%s-%s %s:%s:%s' "${t:0:4}" "${t:4:2}" "${t:6:2}" "${t:8:2}" "${t:10:2}" "${t:12:2}" -} - -is_migration() { # $1=basename -> 0 if a .sql/.go file with a 14-digit prefix - local b="$1" - case "$b" in - *.sql | *.go) ;; - *) return 1 ;; - esac - [[ "${b%%_*}" =~ ^[0-9]{14}$ ]] -} - -if ! git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then - printf '❌ Cannot resolve base ref "%s". In CI, check out with fetch-depth: 0.\n' "$BASE_REF" >&2 - exit 1 -fi - -# --- Newest timestamp already on the base branch --- -base_max="" -base_max_file="" -while IFS= read -r f; do - [ -z "$f" ] && continue - b="$(basename "$f")" - is_migration "$b" || continue - ts="${b%%_*}" - if [[ "$ts" > "$base_max" ]]; then - base_max="$ts" - base_max_file="$f" - fi -done < <(git ls-tree -r --name-only "$BASE_REF" -- "$MIGRATIONS_DIR" 2>/dev/null) - -# --- Ordering + naming on files added by this PR --- -while IFS= read -r f; do - [ -z "$f" ] && continue - b="$(basename "$f")" - case "$b" in - *.sql) ;; # any .sql in this dir must be a migration - *.go) [[ "$b" == [0-9]* ]] || continue ;; # non-timestamped .go = helper (e.g. migration.go), skip - *) continue ;; - esac - if [ "${f%/*}" != "$MIGRATIONS_DIR" ]; then - report "$f" "❌ Migration file in a subdirectory: $f - Migrations must live directly in $MIGRATIONS_DIR/ — only $MIGRATIONS_DIR/*.sql (and - top-level .go migrations) are embedded, so a nested file would be SILENTLY SKIPPED. - Move it to $MIGRATIONS_DIR/$b." - continue - fi - if ! [[ "$b" =~ $NAME_RE ]]; then - report "$f" "❌ Malformed migration filename: $f - Expected YYYYMMDDHHMMSS_lower_snake_name.(sql|go); the name segment must be lowercase. - Regenerate with: make migration-sql name= (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 4ed971491..73475a53a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,14 +37,5 @@ AGENTS.md *.wasm *.ndp openspec/ -.agents go.work* -.worktrees/ -.playwright-mcp/ - -# Temp benchmark files -zz_*_test.go - -# wazero compilation cache for the plugins test suite -/plugins/testdata/.wazero-cache/ -/plugins/testdata/*.stage/ +.worktrees/ \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 923c06c7a..28eb375a5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -9,12 +9,10 @@ linters: - asciicheck - bidichk - bodyclose - - containedctx - copyloopvar - dogsled - durationcheck - errorlint - - forbidigo - gocritic - gocyclo - goprintffuncname @@ -28,9 +26,6 @@ linters: disable: - staticcheck settings: - errcheck: - exclude-functions: - - (*github.com/zeebo/xxh3.Hasher).Write gocritic: disable-all: true enabled-checks: @@ -41,14 +36,6 @@ linters: - G401 - G505 - G115 - forbidigo: - forbid: - - pattern: 'tx\.Exec$' - msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context" - - pattern: 'tx\.Query$' - msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context" - - pattern: 'tx\.QueryRow$' - msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context" govet: enable: - nilness @@ -58,12 +45,6 @@ linters: - gosec path: _test\.go text: "G703" - - path: _test\.go - linters: - - containedctx - - path-except: 'db/migrations/' - linters: - - forbidigo generated: lax presets: - comments @@ -75,8 +56,6 @@ linters: - builtin$ - examples$ - node_modules - - _gen\.go$ - - .worktrees formatters: exclusions: generated: lax diff --git a/.octocov.yml b/.octocov.yml deleted file mode 100644 index c4ad10712..000000000 --- a/.octocov.yml +++ /dev/null @@ -1,44 +0,0 @@ -# Code coverage reporting for pull requests. See https://github.com/k1LoW/octocov -# The 30s default is not enough: scanning this repo's artifacts for the baseline -# eats most of it, leaving none for the report upload. -timeout: 5m -coverage: - # A single pre-merged profile: octocov reports statements for one path, but - # switches to line counting when it merges several itself. - paths: - - coverage.out - # Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated. - # Both patterns need the '**/' prefix: the comment workflow has no source tree, - # so octocov cannot shorten the profile's import paths to repo-relative ones. - exclude: - - '**/tests/**' - - '**/*_gen.go' -codeToTestRatio: - # Needs the pull request's own source, which the comment workflow must not - # check out: it holds a write token. - if: env.COVERAGE_COMMENT != 'true' - code: - - '**/*.go' - - '!**/*_test.go' - - '!**/*_gen.go' - test: - - '**/*_test.go' -testExecutionTime: - if: true - steps: - - Test with coverage - - Test plugins -diff: - datastores: - - artifact://${GITHUB_REPOSITORY} -comment: - # Only the 'Report coverage on PR' workflow sets this: a pull_request run from - # a fork gets a read-only token, so commenting from here 403s. - if: env.COVERAGE_COMMENT == 'true' - updatePrevious: true -summary: - if: true -report: - if: is_default_branch - datastores: - - artifact://${GITHUB_REPOSITORY} diff --git a/Dockerfile b/Dockerfile index 5fc0529f0..2ac5a7ed4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcross +FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:26.1-debian AS osxcross ######################################################################################################################## ### Build xx (original image: tonistiigi/xx) -FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build # v1.9.0 ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50 @@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/ ######################################################################################################################## ### Build Navidrome UI -FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui WORKDIR /app # Install node dependencies @@ -43,7 +43,7 @@ COPY --from=ui /build /build ######################################################################################################################## ### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen) -FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine COPY --from=xx / / ARG TARGETPLATFORM @@ -69,15 +69,12 @@ RUN --mount=type=bind,source=. \ set -e xx-go --wrap export CGO_ENABLED=1 - BUILD_TAGS=$(./release/build-tags.sh) # -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve. - go build -tags="${BUILD_TAGS}" -ldflags="-w -s \ + go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \ -linkmode=external -extldflags '-latomic' \ -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ -o /out/navidrome . - # Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738). - ./release/verify-binary.sh /out/navidrome # Fail the build if the binary is accidentally statically linked: dlopen (and # therefore native libwebp detection) only works with a dynamic interpreter. file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; } @@ -85,7 +82,7 @@ EOT ######################################################################################################################## ### Build Navidrome binary for standalone distribution (static glibc, cross-compiled) -FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS base +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base RUN apt-get update && apt-get install -y clang lld COPY --from=xx / / WORKDIR /workspace @@ -111,12 +108,11 @@ RUN --mount=type=bind,source=. \ --mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \ --mount=type=cache,target=/root/.cache \ --mount=type=cache,target=/go/pkg/mod </dev/null || true + cat $(go env GOENV) # Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler. # So let's use gcc for everything except Darwin. @@ -125,25 +121,14 @@ RUN --mount=type=bind,source=. \ export CXX=$(xx-info)-g++ export LD_EXTRA="-extldflags '-static -latomic'" fi - # GNU ld corrupts the R_ARM_IRELATIVE addends of libatomic's ifunc resolvers - # (wrong address, Thumb bit lost) once .text outgrows the 16MB Thumb branch - # range, making static arm binaries jump to garbage inside glibc's ifunc - # resolution and crash before main() (issue #5738). Link 32-bit arm with LLD, - # which emits correct addends. - if [ "$(xx-info arch)" = "arm" ]; then - export LD_EXTRA="-extldflags '-static -latomic -fuse-ld=lld'" - fi if [ "$(xx-info os)" = "windows" ]; then export EXT=".exe" fi - BUILD_TAGS=$(./release/build-tags.sh) - go build -tags="${BUILD_TAGS}" -ldflags="${LD_EXTRA} -w -s \ + go build -tags=netgo,sqlite_fts5 -ldflags="${LD_EXTRA} -w -s \ -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ -o /out/navidrome${EXT} . - # Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738). - ./release/verify-binary.sh /out/navidrome* EOT # Verify if the binary was built for the correct platform and it is statically linked @@ -152,52 +137,19 @@ RUN xx-verify --static /out/navidrome* FROM scratch AS binary COPY --from=build /out / -######################################################################################################################## -### Build no-op stubs for mpv's video-output libraries -# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio. -# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls. -FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs -COPY --from=xx / / -RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm -ARG TARGETPLATFORM -RUN xx-apk add --no-cache musl-dev -RUN < /tmp/stub.c - test -s /tmp/stub.c - xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c - xx-verify /out/$so - done -EOT - ######################################################################################################################## ### Build Final Image -FROM alpine:3.22 AS final +FROM public.ecr.aws/docker/library/alpine:3.20 AS final LABEL maintainer="deluan@navidrome.org" LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" # Install runtime dependencies # - libwebp + symlinks: enables native WebP encoding via purego/dlopen -# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer, -# otherwise the deleted bytes still ship in the image. -RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \ +RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \ for lib in libwebp libwebpdemux libwebpmux; do \ target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \ [ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \ - done && \ - rm -rf /usr/lib/gallium-pipe /usr/lib/dri \ - /usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \ - /usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so* - -COPY --from=mpv-stubs /out/ /usr/lib/ -RUN mpv --no-video --ao=null --version > /dev/null + done # Copy navidrome binary (musl build for Docker, enables native libwebp) COPY --from=build-alpine /out/navidrome /app/ @@ -207,6 +159,7 @@ ENV ND_MUSICFOLDER=/music ENV ND_DATAFOLDER=/data ENV ND_CONFIGFILE=/data/navidrome.toml ENV ND_PORT=4533 +ENV ND_ENABLEWEBPENCODING=true RUN touch /.nddockerenv EXPOSE ${ND_PORT} diff --git a/Makefile b/Makefile index f31d98a01..e303017c7 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 --abbrev=0 2>/dev/null || echo v0.0.0)-SNAPSHOT +GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT else GIT_SHA=source_archive GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT @@ -20,11 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -GOLANGCI_LINT_VERSION ?= v2.14.0 -VACUUM_VERSION ?= v0.30.6 -OAPI_CODEGEN_VERSION ?= v2.8.0 -OASDIFF_VERSION ?= v1.32.1 -API_DIFF_BASE ?= origin/master +GOLANGCI_LINT_VERSION ?= v2.12.0 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") @@ -96,45 +92,6 @@ install-golangci-lint: ##@Development Install golangci-lint if not present fi .PHONY: install-golangci-lint -install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin - @STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \ - if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \ - echo "Installing OpenAPI tools..."; \ - GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \ - GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \ - GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \ - rm -f bin/.api-tools-* && touch $$STAMP; \ - fi -.PHONY: install-api-tools - -api-lint: install-api-tools ##@Development Lint the OpenAPI spec - ./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml -.PHONY: api-lint - -api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled - ./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml - ./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json -.PHONY: api-bundle - -api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec - ./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json -.PHONY: api-gen - -api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE) - @git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \ - BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \ - if [ -z "$$BASE" ]; then \ - echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \ - BASE=$(API_DIFF_BASE); \ - fi; \ - if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \ - git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \ - ./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \ - else \ - echo "No bundled spec at $$BASE; skipping breaking-change check"; \ - fi -.PHONY: api-diff - lint: install-golangci-lint ##@Development Lint Go code PATH=./bin:$$PATH golangci-lint run --timeout 5m .PHONY: lint @@ -154,13 +111,12 @@ wire: check_go_env ##@Development Update Dependency Injection go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./... .PHONY: wire -gen: check_go_env api-gen ##@Development Run go generate for code generation +gen: check_go_env ##@Development Run go generate for code generation go generate ./... - cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust - cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types - cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types - cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types - cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types + cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host + cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust + cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust + cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities go mod tidy -C plugins/pdk/go .PHONY: gen diff --git a/README.md b/README.md index 4bc85e6a6..0ae5bdfaf 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,6 @@ A share of the revenue helps fund the development of Navidrome at no additional - **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided - Ready to use binaries for all major platforms, including **Raspberry Pi** - Automatically **monitors your library** for changes, importing new files and reloading new metadata - - Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`) - **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com) - **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps) - **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported** diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go index 03f37af19..31150c673 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" @@ -13,26 +13,15 @@ import ( "strings" "github.com/microcosm-cc/bluemonday" - "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) const apiBaseURL = "https://api.deezer.com" const authBaseURL = "https://auth.deezer.com" -// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200 -// and no rate-limit headers, so the body code is the only signal. -const errCodeQuota = 4 - -type deezerError struct { - Type string `json:"type"` - Message string `json:"message"` - Code int `json:"code"` -} - -func (e *deezerError) Error() string { - return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message) -} +var ( + ErrNotFound = errors.New("deezer: not found") +) type httpDoer interface { Do(req *http.Request) (*http.Response, error) @@ -67,7 +56,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A } if len(results.Data) == 0 { - return nil, agents.ErrNotFound + return nil, ErrNotFound } return results.Data, nil } @@ -85,31 +74,20 @@ func (c *client) makeRequest(req *http.Request, response any) error { return err } - // Checked before the status: a throttled request still answers 200, and decoding its body - // into a result type yields an empty one, which reads as "nothing found". - if err := parseBodyError(data); err != nil { - return err - } if resp.StatusCode != 200 { - return fmt.Errorf("deezer http status: (%d)", resp.StatusCode) + return c.parseError(data) } return json.Unmarshal(data, response) } -// parseBodyError returns the error Deezer reported in the body, or nil when it reported none. -func parseBodyError(data []byte) error { - var body errorResponse - // Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer. - _ = json.Unmarshal(data, &body) - switch { - case body.Error == nil: - return nil - case body.Error.Code == errCodeQuota: - return errors.Join(body.Error, agents.ErrRetryLater) - default: - return body.Error +func (c *client) parseError(data []byte) error { + var deezerError Error + err := json.Unmarshal(data, &deezerError) + if err != nil { + return err } + return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message) } func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) { diff --git a/adapters/deezer/client_test.go b/adapters/deezer/client_test.go index 84d981a76..9fa7afdd9 100644 --- a/adapters/deezer/client_test.go +++ b/adapters/deezer/client_test.go @@ -2,14 +2,12 @@ package deezer import ( "bytes" - "errors" "fmt" "io" "net/http" "os" "time" - "github.com/navidrome/navidrome/core/agents" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -43,37 +41,7 @@ var _ = Describe("client", func() { }) _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) - Expect(err).To(MatchError(agents.ErrNotFound)) - }) - - // Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal. - It("reports an exhausted quota as a retryable error, not as a missing artist", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString( - `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), - }) - - _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) - Expect(err).To(HaveOccurred()) - Expect(err).ToNot(MatchError(agents.ErrNotFound), - "a throttled lookup would otherwise settle the artist as having no image") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - Expect(err.Error()).To(ContainSubstring("Quota limit exceeded")) - }) - - It("reports a non-quota body error as a plain error", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString( - `{"error":{"type":"Exception","message":"Invalid query","code":100}}`)), - }) - - _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) - Expect(err).To(HaveOccurred()) - Expect(err).ToNot(MatchError(agents.ErrNotFound)) - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(), - "only a throttle asks the caller to come back later") + Expect(err).To(MatchError(ErrNotFound)) }) }) diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index d3570a29f..ed3071766 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -1,11 +1,10 @@ package deezer import ( - "cmp" "context" "errors" "fmt" - "slices" + "net/http" "strings" "github.com/navidrome/navidrome/conf" @@ -14,7 +13,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/slice" ) @@ -36,7 +34,9 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface { dataStore: dataStore, languages: conf.Server.Deezer.Languages, } - httpClient := httpclient.New(consts.DefaultHttpClientTimeOut) + httpClient := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) agent.client = newClient(cachedHttpClient) return agent @@ -68,29 +68,21 @@ func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([ {artist.PictureSmall, deezerApiPictureSmallSize}, } for _, imgData := range possibleImages { - if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) { + if imgData.URL != "" { res = append(res, agents.ExternalImage{ URL: imgData.URL, Size: imgData.Size, }) } } - if len(res) == 0 { - return nil, agents.ErrNotFound - } return res, nil } -// deezerEmptyPicturePath is Deezer's empty-image-id path shape for artists with no picture -// (…/images/artist//1000x1000-…), which serves a generic silhouette on any CDN host. -const deezerEmptyPicturePath = "/images/artist//" - -func isPlaceholderPicture(url string) bool { - return strings.Contains(url, deezerEmptyPicturePath) -} - func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit) + if errors.Is(err, ErrNotFound) || len(artists) == 0 { + return nil, agents.ErrNotFound + } if err != nil { return nil, err } @@ -103,32 +95,13 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e } } - // Deezer's RANKING order isn't reliable for homonyms: rank name matches - // ahead of non-matches, prefer an exact-case match, then the most fans. - rank := func(a Artist) int { - switch { - case a.Name == name: - return 2 - case strings.EqualFold(a.Name, name): - return 1 - default: - return 0 - } - } - slices.SortFunc(artists, func(a, b Artist) int { - return cmp.Or( - cmp.Compare(rank(b), rank(a)), - cmp.Compare(b.NbFan, a.NbFan), - cmp.Compare(a.ID, b.ID), - ) - }) - best := artists[0] - if !strings.EqualFold(best.Name, name) { - log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name) + // If the first one has the same name, that's the one + if !strings.EqualFold(artists[0].Name, name) { + log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name) return nil, agents.ErrNotFound } - log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan) - return new(best), nil + log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link) + return &artists[0], err } func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) { diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index 82d02c244..4dd251585 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -3,7 +3,6 @@ package deezer import ( "bytes" "context" - "errors" "fmt" "io" "net/http" @@ -35,130 +34,6 @@ var _ = Describe("deezerAgent", func() { }) }) - Describe("searchArtist", func() { - var agent *deezerAgent - var httpClient *fakeHttpClient - - BeforeEach(func() { - httpClient = &fakeHttpClient{} - agent = &deezerAgent{ - dataStore: &tests.MockDataStore{}, - client: newClient(httpClient), - } - }) - - It("picks the exact-name match with the most fans when several share the name", func() { - // Deezer RANKING order returns a low-popularity homonym first (see issue #5802) - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[ - {"id":61045802,"name":"Queen","nb_fan":75}, - {"id":141954732,"name":"Queen","nb_fan":397}, - {"id":135041032,"name":"Queen(Ares)","nb_fan":133}, - {"id":183179807,"name":"Queen","nb_fan":53}, - {"id":412,"name":"Queen","nb_fan":12744378} - ],"total":5}`)), - }) - - artist, err := agent.searchArtist(ctx, "Queen") - - Expect(err).ToNot(HaveOccurred()) - Expect(artist.ID).To(Equal(412)) - }) - - It("matches the name case-insensitively", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[ - {"id":1,"name":"QUEEN","nb_fan":10}, - {"id":2,"name":"queen","nb_fan":20} - ],"total":2}`)), - }) - - artist, err := agent.searchArtist(ctx, "Queen") - - Expect(err).ToNot(HaveOccurred()) - Expect(artist.ID).To(Equal(2)) - }) - - // The artwork worker settles an artist as "no image" on agents.ErrNotFound, so a throttled - // lookup reaching that here would record a permanent absence. - It("surfaces an exhausted quota instead of reporting the artist as not found", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString( - `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), - }) - - _, err := agent.searchArtist(ctx, "Queen") - - Expect(err).To(HaveOccurred()) - Expect(err).ToNot(MatchError(agents.ErrNotFound)) - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - }) - - It("returns ErrNotFound when no result matches the name exactly", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[ - {"id":1,"name":"Queens of the Stone Age","nb_fan":100} - ],"total":1}`)), - }) - - _, err := agent.searchArtist(ctx, "Queen") - - Expect(err).To(MatchError(agents.ErrNotFound)) - }) - }) - - Describe("GetArtistImages", func() { - var agent *deezerAgent - var httpClient *fakeHttpClient - - BeforeEach(func() { - httpClient = &fakeHttpClient{} - agent = &deezerAgent{ - dataStore: &tests.MockDataStore{}, - client: newClient(httpClient), - } - }) - - It("returns the real images when the artist has a picture", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[ - {"id":412,"name":"Queen","nb_fan":12744378, - "picture_xl":"https://cdn-images.dzcdn.net/images/artist/abc/1000x1000-000000-80-0-0.jpg", - "picture_big":"https://cdn-images.dzcdn.net/images/artist/abc/500x500-000000-80-0-0.jpg"} - ],"total":1}`)), - }) - - images, err := agent.GetArtistImages(ctx, "", "Queen", "") - - Expect(err).ToNot(HaveOccurred()) - Expect(images).To(HaveLen(2)) - Expect(images[0].URL).To(ContainSubstring("1000x1000")) - }) - - It("returns ErrNotFound when the artist only has empty-id placeholder pictures", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[ - {"id":412,"name":"Queen","nb_fan":12744378, - "picture_xl":"https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg", - "picture_big":"https://cdn-images.dzcdn.net/images/artist//500x500-000000-80-0-0.jpg", - "picture_medium":"https://cdn-images.dzcdn.net/images/artist//250x250-000000-80-0-0.jpg", - "picture_small":"https://cdn-images.dzcdn.net/images/artist//56x56-000000-80-0-0.jpg"} - ],"total":1}`)), - }) - - images, err := agent.GetArtistImages(ctx, "", "Queen", "") - - Expect(err).To(MatchError(agents.ErrNotFound)) - Expect(images).To(BeEmpty()) - }) - }) - Describe("GetArtistBiography - Language Fallback", func() { var agent *deezerAgent var httpClient *langAwareHttpClient diff --git a/adapters/deezer/responses.go b/adapters/deezer/responses.go index 6cc95dd4b..266c44c62 100644 --- a/adapters/deezer/responses.go +++ b/adapters/deezer/responses.go @@ -22,8 +22,12 @@ type Artist struct { Type string `json:"type"` } -type errorResponse struct { - Error *deezerError `json:"error"` +type Error struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` } type RelatedArtists struct { diff --git a/adapters/deezer/responses_test.go b/adapters/deezer/responses_test.go index 5a3fc7798..a9de5c5fb 100644 --- a/adapters/deezer/responses_test.go +++ b/adapters/deezer/responses_test.go @@ -26,7 +26,7 @@ var _ = Describe("Responses", func() { Describe("Error", func() { It("parses the error response correctly", func() { - var errorResp errorResponse + var errorResp Error body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`) err := json.Unmarshal(body, &errorResp) Expect(err).To(BeNil()) diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go index 0f9a90d94..4a93f5b83 100644 --- a/adapters/gotaglib/end_to_end_test.go +++ b/adapters/gotaglib/end_to_end_test.go @@ -8,6 +8,7 @@ import ( "github.com/djherbis/times" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -90,7 +91,8 @@ var _ = Describe("Extractor", func() { info.FileInfo = testFileInfo{FileInfo: fileInfo} metadata := metadata.New(path, info) - return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID")) + mf := metadata.ToMediaFile(1, "folderID") + return &mf } BeforeEach(func() { @@ -107,7 +109,7 @@ var _ = Describe("Extractor", func() { Expect(mf.RGAlbumPeak).To(Equal(albumPeak)) }, Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil), - Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)), + Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)), ) }) @@ -118,8 +120,8 @@ var _ = Describe("Extractor", func() { DisplayTitle: "", Lang: code, Line: []model.Line{ - {Start: new(int64(0)), Value: "This is"}, - {Start: new(int64(2500)), Value: secondLine}, + {Start: gg.P(int64(0)), Value: "This is"}, + {Start: gg.P(int64(2500)), Value: secondLine}, }, Offset: nil, Synced: true, diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index dbd73c30d..02c198120 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -18,7 +18,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/httpclient" "golang.org/x/net/html" ) @@ -60,7 +59,9 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent { secret: conf.Server.LastFM.Secret, sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } - hc := httpclient.New(consts.DefaultHttpClientTimeOut) + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) l.httpClient = chc l.client = newClient(l.apiKey, l.secret, chc) @@ -92,7 +93,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin var resp agents.AlbumInfo for _, lang := range l.languages { var err error - a, err = l.callAlbumGetInfo(ctx, name, artist, lang) + a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang) if err != nil { return nil, err } @@ -113,7 +114,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin } func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { - a, err := l.callAlbumGetInfo(ctx, name, artist, l.languages[0]) + a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0]) if err != nil { return nil, err } @@ -230,9 +231,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti res := make([]agents.Song, 0, len(resp)) for _, t := range resp { res = append(res, agents.Song{ - Name: t.Name, - MBID: t.MBID, - Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}}, + Name: t.Name, + MBID: t.MBID, + Artist: t.Artist.Name, + ArtistMBID: t.Artist.MBID, }) } return res, nil @@ -241,10 +243,6 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti var ( artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`) artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name - - // Not a RetryLaterError on purpose: parking the agent would also stall its API-backed - // methods, which the page block does not affect. - errNoArtistPage = errors.New("no artist image in Last.fm page") ) func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) { @@ -271,9 +269,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) var res []agents.ExternalImage n := cascadia.Query(node, artistOpenGraphQuery) if n == nil { - // A real artist page always has og:image; its absence means a bot challenge or a redesign. - log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL) - return nil, errNoArtistPage + return res, nil } for _, attr := range n.Attr { if attr.Key != "content" { @@ -291,18 +287,22 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) return res, nil } -// callAlbumGetInfo matches on name+artist only. Last.fm's album.getInfo by MBID is unreliable — -// a correct MBID can return a different album (or none) — so the MBID is deliberately not passed. -func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) { - a, err := l.client.albumGetInfo(ctx, name, artist, "", lang) +func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) { + a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang) + var lfErr *lastFMError + isLastFMError := errors.As(err, &lfErr) + + if mbid != "" && (isLastFMError && lfErr.Code == 6) { + log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid) + return l.callAlbumGetInfo(ctx, name, artist, "", lang) + } + if err != nil { - if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 { - // A not-found is a definitive absence, not a fault: return the shared sentinel so the - // artwork worker's breaker/transient checks don't retry it, and log it at Debug. - log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist) - return nil, agents.ErrNotFound + if isLastFMError && lfErr.Code == 6 { + log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err) + } else { + log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err) } - log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err) return nil, err } return a, nil @@ -314,12 +314,6 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang s a, err := l.client.artistGetInfo(ctx, name, lang) if err != nil { - if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 { - // A not-found is a definitive absence, not a fault: return the shared sentinel so it - // doesn't trip the artwork worker's breaker, and log at Debug instead of Error. - log.Debug(ctx, "Artist not found in Last.fm", "artist", name) - return nil, agents.ErrNotFound - } log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err) return nil, err } @@ -411,8 +405,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err) return errors.Join(err, scrobbler.ErrRetryLater) } - // 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client. - if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) { + if lfErr.Code == 11 || lfErr.Code == 16 { return errors.Join(err, scrobbler.ErrRetryLater) } return errors.Join(err, scrobbler.ErrUnrecoverable) diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 0f68bd46b..94788b8bd 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -100,15 +100,6 @@ var _ = Describe("lastfmAgent", func() { Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) }) - - It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), - StatusCode: 200, - } - _, err := agent.GetArtistBiography(ctx, "123", "U2", "") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - }) }) Describe("Language Fallback", func() { @@ -318,11 +309,11 @@ var _ = Describe("lastfmAgent", func() { f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{ - {Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}}, - {Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}}, - {Name: "Don't You Want Me", MBID: "", Artists: []agents.Artist{{Name: "The Human League", MBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}}}, - {Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}}, - {Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artists: []agents.Artist{{Name: "New Order", MBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}}}, + {Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}, + {Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}, + {Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}, + {Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"}, + {Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}, })) Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough")) @@ -357,7 +348,7 @@ var _ = Describe("lastfmAgent", func() { var httpClient *tests.FakeHttpClient var track *model.MediaFile BeforeEach(func() { - _ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1") + _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") httpClient = &tests.FakeHttpClient{} client := newClient("API_KEY", "SECRET", httpClient) agent = lastFMConstructor(ds) @@ -506,16 +497,6 @@ var _ = Describe("lastfmAgent", func() { Expect(err).To(MatchError(scrobbler.ErrRetryLater)) }) - It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), - StatusCode: 200, - } - - err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) - Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) - }) - It("returns ErrRetryLater on http errors", func() { httpClient.Res = http.Response{ Body: io.NopCloser(bytes.NewBufferString(`internal server error`)), @@ -558,10 +539,7 @@ var _ = Describe("lastfmAgent", func() { URL: "https://www.last.fm/music/Cher/Believe", })) Expect(httpClient.RequestCount).To(Equal(1)) - // MBID is deliberately not sent — album.getInfo matches on name+artist only. - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) - Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("Believe")) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Cher")) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62")) }) It("returns empty images if no images are available", func() { @@ -580,7 +558,7 @@ var _ = Describe("lastfmAgent", func() { _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") Expect(err).To(HaveOccurred()) Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) }) It("returns an error if Last.fm call returns an error", func() { @@ -588,17 +566,23 @@ var _ = Describe("lastfmAgent", func() { _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") Expect(err).To(HaveOccurred()) Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) }) - It("returns an error when Last.fm returns an error 6 (album not found)", func() { + It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() { httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} - _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") + _, err := agent.GetAlbumInfo(ctx, "123", "U2", "") Expect(err).To(HaveOccurred()) - // A definitive not-found must satisfy the sentinel, or the artwork worker retries it. - Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue()) Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + }) + + Context("MBID non existent in Last.fm", func() { + It("calls again when last.fm returns an error 6", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, _ = agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") + Expect(httpClient.RequestCount).To(Equal(2)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + }) }) }) @@ -629,13 +613,6 @@ var _ = Describe("lastfmAgent", func() { Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png")) }) - It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() { - apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} - _, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "") - // Not a fault: runs of missing artists must not trip the worker's circuit breaker. - Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue()) - }) - It("returns empty list if image is the ignored default image", func() { fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") apiClient.Res = http.Response{Body: fApi, StatusCode: 200} @@ -648,41 +625,18 @@ var _ = Describe("lastfmAgent", func() { Expect(images).To(BeEmpty()) }) - It("errors when the page has no meta tags", func() { + It("returns empty list if page has no meta tags", func() { fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") apiClient.Res = http.Response{Body: fApi, StatusCode: 200} fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html") httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} - _, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(err).To(MatchError(errNoArtistPage)) - }) - - It("errors when Last.fm serves a bot challenge page", func() { - fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - apiClient.Res = http.Response{Body: fApi, StatusCode: 200} - - fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html") - httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} - images, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(err).To(MatchError(errNoArtistPage)) + Expect(err).ToNot(HaveOccurred()) Expect(images).To(BeEmpty()) }) - It("does not park the agent: the failure is not a retry-later", func() { - // A RetryLaterError would cool down the agent's API-backed methods too. - fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - apiClient.Res = http.Response{Body: fApi, StatusCode: 200} - - fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html") - httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} - - _, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse()) - }) - It("returns error if API call fails", func() { apiClient.Err = errors.New("api error") _, err := agent.GetArtistImages(ctx, "123", "U2", "") diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index ec1bb9290..162ae9037 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -18,7 +18,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/req" ) @@ -42,7 +41,9 @@ func NewRouter(ds model.DataStore) *Router { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() - hc := httpclient.New(consts.DefaultHttpClientTimeOut) + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } r.client = newClient(r.apiKey, r.secret, hc) return r } @@ -76,13 +77,6 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { return } resp["status"] = key != "" - linkToken, err := createLinkToken(u.ID) - if err != nil { - log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err) - _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) - return - } - resp["linkToken"] = linkToken _ = rest.RespondWithJSON(w, http.StatusOK, resp) } @@ -103,17 +97,11 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) { _ = rest.RespondWithError(w, http.StatusBadRequest, "token not received") return } - linkToken, err := p.String("uid") + uid, err := p.String("uid") if err != nil { _ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received") return } - uid, err := verifyLinkToken(linkToken) - if err != nil { - log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err) - _ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token") - return - } // Need to add user to context, as this is a non-authenticated endpoint, so it does not // automatically contain any user info @@ -132,7 +120,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) { func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error { sessionKey, err := s.client.getSession(ctx, token) if err != nil { - log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, + log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token, "requestId", middleware.GetReqID(ctx), err) return err } diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go deleted file mode 100644 index 476daf443..000000000 --- a/adapters/lastfm/auth_router_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package lastfm - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "time" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("auth_router", func() { - var ( - ds *tests.MockDataStore - userProps *tests.MockedUserPropsRepo - httpClient *tests.FakeHttpClient - router *Router - ) - - const ( - victimID = "victim-user-id" - attackerID = "attacker-user-id" - ) - - BeforeEach(func() { - userProps = &tests.MockedUserPropsRepo{} - ds = &tests.MockDataStore{ - MockedProperty: &tests.MockedPropertyRepo{}, - MockedUserProps: userProps, - } - auth.Init(ds) - - httpClient = &tests.FakeHttpClient{} - router = &Router{ - ds: ds, - apiKey: "API_KEY", - secret: "SECRET", - sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, - } - router.client = newClient(router.apiKey, router.secret, httpClient) - router.Handler = router.routes() - }) - - storedSessionKey := func(userID string) string { - key, _ := userProps.Get(GinkgoT().Context(), 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")) - }) - - It("rejects a Jellyfin access token", func() { - usr := &model.User{ID: "u1", UserName: "johndoe"} - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - - _, err = verifyLinkToken(tokenStr) - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/adapters/lastfm/client.go b/adapters/lastfm/client.go index 7b2601cf9..726df1360 100644 --- a/adapters/lastfm/client.go +++ b/adapters/lastfm/client.go @@ -5,7 +5,6 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -15,15 +14,11 @@ import ( "strings" "time" - "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) const ( apiBaseUrl = "https://ws.audioscrobbler.com/2.0/" - // errCodeRateLimit is Last.fm's "rate limit exceeded"; it arrives in the body, with HTTP 200 - // and no rate-limit headers, so the body code is the only signal. - errCodeRateLimit = 29 ) type lastFMError struct { @@ -49,12 +44,6 @@ type client struct { hc httpDoer } -// escapePlus works around Last.fm decoding artist.* and track.* params twice, turning "+" into a space. -// album.getInfo decodes only once, so it must not use this. -func escapePlus(s string) string { - return strings.ReplaceAll(s, "+", "%2B") -} - func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) { params := url.Values{} params.Add("method", "album.getInfo") @@ -72,7 +61,7 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) { params := url.Values{} params.Add("method", "artist.getInfo") - params.Add("artist", escapePlus(name)) + params.Add("artist", name) params.Add("lang", lang) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -84,7 +73,7 @@ func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (* func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) { params := url.Values{} params.Add("method", "artist.getSimilar") - params.Add("artist", escapePlus(name)) + params.Add("artist", name) params.Add("limit", strconv.Itoa(limit)) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -96,7 +85,7 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) ( func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) { params := url.Values{} params.Add("method", "artist.getTopTracks") - params.Add("artist", escapePlus(name)) + params.Add("artist", name) params.Add("limit", strconv.Itoa(limit)) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -108,8 +97,8 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) { params := url.Values{} params.Add("method", "track.getSimilar") - params.Add("track", escapePlus(name)) - params.Add("artist", escapePlus(artist)) + params.Add("track", name) + params.Add("artist", artist) params.Add("limit", strconv.Itoa(limit)) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -236,11 +225,7 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu return nil, jsonErr } if response.Error != 0 { - var err error = &lastFMError{Code: response.Error, Message: response.Message} - if response.Error == errCodeRateLimit { - err = errors.Join(err, &agents.RetryLaterError{}) - } - return &response, err + return &response, &lastFMError{Code: response.Error, Message: response.Message} } return &response, nil diff --git a/adapters/lastfm/client_test.go b/adapters/lastfm/client_test.go index f72c84b91..271ae1419 100644 --- a/adapters/lastfm/client_test.go +++ b/adapters/lastfm/client_test.go @@ -35,15 +35,6 @@ var _ = Describe("client", func() { Expect(album.Name).To(Equal("Believe")) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo")) }) - - It("does not double-encode plus signs", func() { - f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - _, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en") - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine")) - }) }) Describe("artistGetInfo", func() { @@ -57,15 +48,6 @@ var _ = Describe("client", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo")) }) - It("double-encodes plus signs in the artist name", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - _, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en") - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine")) - }) - It("fails if Last.fm returns an http status != 200", func() { httpClient.Res = http.Response{ Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)), @@ -125,15 +107,6 @@ var _ = Describe("client", func() { Expect(len(similar.Artists)).To(Equal(2)) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar")) }) - - It("double-encodes plus signs in the artist name", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - _, err := client.artistGetSimilar(context.Background(), "+44", 2) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44")) - }) }) Describe("artistGetTopTracks", func() { @@ -146,15 +119,6 @@ var _ = Describe("client", func() { Expect(len(top.Track)).To(Equal(2)) Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks")) }) - - It("double-encodes plus signs in the artist name", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - _, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory")) - }) }) Describe("trackGetSimilar", func() { @@ -171,17 +135,6 @@ var _ = Describe("client", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough")) }) - It("double-encodes plus signs in the track and artist names", func() { - f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - _, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5) - Expect(err).ToNot(HaveOccurred()) - query := httpClient.SavedRequest.URL.Query() - Expect(query.Get("track")).To(Equal("1%2B1")) - Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers")) - }) - It("returns empty list when no similar tracks found", func() { f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} diff --git a/adapters/lastfm/link_token.go b/adapters/lastfm/link_token.go deleted file mode 100644 index fd8ceb3c9..000000000 --- a/adapters/lastfm/link_token.go +++ /dev/null @@ -1,50 +0,0 @@ -package lastfm - -import ( - "errors" - "time" - - "github.com/navidrome/navidrome/core/auth" -) - -const ( - linkTokenScope = "lastfm-link" - linkTokenTTL = 5 * time.Minute -) - -// createLinkToken issues a signed token binding the Last.fm callback to the -// user who initiated the OAuth flow. It travels back through Last.fm via the -// `cb` URL in place of the previously-trusted raw `uid` query parameter. -func createLinkToken(userID string) (string, error) { - claims := map[string]any{ - "uid": userID, - "scope": linkTokenScope, - "exp": time.Now().Add(linkTokenTTL).UTC().Unix(), - } - return auth.EncodeToken(claims) -} - -// verifyLinkToken validates a signed link token and returns the encoded user ID. -// It enforces both the signature/expiry (via the underlying JWT verifier) and a -// dedicated scope claim, preventing tokens minted for other purposes (e.g. a -// regular session JWT) from being accepted here. -func verifyLinkToken(tokenStr string) (string, error) { - token, err := auth.DecodeAndVerifyToken(tokenStr) - if err != nil { - return "", err - } - // jwtauth treats a token without `exp` as non-expiring; require it - // explicitly so an accidental regression cannot mint permanent tokens. - if exp, ok := token.Expiration(); !ok || exp.IsZero() { - return "", errors.New("link token missing expiration") - } - var scope string - if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope { - return "", errors.New("invalid link token scope") - } - var uid string - if err := token.Get("uid", &uid); err != nil || uid == "" { - return "", errors.New("invalid link token user ID") - } - return uid, nil -} diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index a59a5393f..826a9672e 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -3,6 +3,7 @@ package listenbrainz import ( "context" "errors" + "net/http" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -11,7 +12,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/slice" ) @@ -33,7 +33,9 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, baseURL: conf.Server.ListenBrainz.BaseURL, } - hc := httpclient.New(consts.DefaultHttpClientTimeOut) + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) l.client = newClient(l.baseURL, chc) return l @@ -139,37 +141,24 @@ func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistNam res := make([]agents.Song, len(resp)) for i, t := range resp { + mbid := "" + if len(t.ArtistMBIDs) > 0 { + mbid = t.ArtistMBIDs[0] + } + res[i] = agents.Song{ - Album: t.ReleaseName, - AlbumMBID: t.ReleaseMBID, - Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs), - Duration: t.DurationMs, - Name: t.RecordingName, - MBID: t.RecordingMbid, + Album: t.ReleaseName, + AlbumMBID: t.ReleaseMBID, + Artist: t.ArtistName, + ArtistMBID: mbid, + Duration: t.DurationMs, + Name: t.RecordingName, + MBID: t.RecordingMbid, } } return res, nil } -// topSongArtists maps the top-recordings response, which carries a single combined display name -// (e.g. "X feat. Y") plus a per-artist MBID list, onto agents.Artist. Names and MBIDs are not -// positionally pairable, so the display name attaches to the first credit and any further MBIDs -// become MBID-only collaborators — still valid identity signals for the matcher. -func topSongArtists(name string, mbids []string) []agents.Artist { - if len(mbids) == 0 { - if name == "" { - return nil - } - return []agents.Artist{{Name: name}} - } - artists := make([]agents.Artist, len(mbids)) - artists[0] = agents.Artist{Name: name, MBID: mbids[0]} - for i, m := range mbids[1:] { - artists[i+1] = agents.Artist{MBID: m} - } - return artists -} - func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) { if mbid == "" { return nil, agents.ErrNotFound @@ -214,7 +203,7 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin songs[i] = agents.Song{ Album: song.ReleaseName, AlbumMBID: song.ReleaseMBID, - Artists: []agents.Artist{{Name: song.Artist}}, + Artist: song.Artist, MBID: song.MBID, Name: song.Name, } diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index d2aa07f1a..df70ec9c4 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() { BeforeEach(func() { ds = &tests.MockDataStore{} ctx = context.Background() - _ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1") + _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") httpClient = &tests.FakeHttpClient{} agent = listenBrainzConstructor(ds) agent.client = newClient("http://localhost:8080", httpClient) @@ -164,19 +164,6 @@ var _ = Describe("listenBrainzAgent", func() { err := agent.Scrobble(ctx, "user-1", sc) Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) - - It("keeps a 429 scrobble for retry and carries the delay", func() { - httpClient.Res = http.Response{ - StatusCode: 429, - Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, - Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)), - } - err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) - Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(7 * time.Second)) - }) }) Describe("GetArtistUrl", func() { @@ -262,22 +249,24 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]agents.Song{ { - ID: "", - Name: "world.execute(me);", - MBID: "9980309d-3480-4e7e-89ce-fce971a452be", - Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, - Album: "Miracle Milk", - AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - Duration: 211912, + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artist: "Mili", + ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, }, { - ID: "", - Name: "String Theocracy", - MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9", - Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, - Album: "String Theocracy", - AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", - Duration: 174000, + ID: "", + Name: "String Theocracy", + MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9", + Artist: "Mili", + ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + Album: "String Theocracy", + AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", + Duration: 174000, }, })) }) @@ -289,45 +278,17 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]agents.Song{ { - ID: "", - Name: "world.execute(me);", - MBID: "9980309d-3480-4e7e-89ce-fce971a452be", - Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, - Album: "Miracle Milk", - AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - Duration: 211912, + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artist: "Mili", + ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, }, })) }) - - It("maps a multi-artist top song to one named artist plus MBID-only collaborators", func() { - body := `[{ - "recording_name": "Collab", - "recording_mbid": "rec-1", - "artist_name": "Drake feat. Future", - "artist_mbids": ["mbid-drake", "mbid-future"], - "release_name": "Album", - "release_mbid": "rel-1", - "length": 200000 - }]` - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200} - data, err := agent.GetArtistTopSongs(ctx, "", "", "mbid-drake", 1) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(HaveLen(1)) - Expect(data[0].Artists).To(Equal([]agents.Artist{ - {Name: "Drake feat. Future", MBID: "mbid-drake"}, - {MBID: "mbid-future"}, - })) - }) - - It("leaves Artists nil when the top song carries no name or MBIDs", func() { - body := `[{"recording_name": "Anon", "recording_mbid": "rec-1", "artist_name": "", "artist_mbids": []}]` - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200} - data, err := agent.GetArtistTopSongs(ctx, "", "", "x", 1) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(HaveLen(1)) - Expect(data[0].Artists).To(BeNil()) - }) }) Describe("GetSimilarArtists", func() { @@ -432,24 +393,26 @@ var _ = Describe("listenBrainzAgent", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) Expect(resp).To(Equal([]agents.Song{ { - ID: "", - Name: "Take On Me", - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - ISRC: "", - Artists: []agents.Artist{{Name: "a‐ha"}}, - Album: "Hunting High and Low", - AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Duration: 0, + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artist: "a‐ha", + ArtistMBID: "", + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, }, { - ID: "", - Name: "Wake Me Up Before You Go‐Go", - MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", - ISRC: "", - Artists: []agents.Artist{{Name: "Wham!"}}, - Album: "Make It Big", - AlbumMBID: "c143d542-48dc-446b-b523-1762da721638", - Duration: 0, + ID: "", + Name: "Wake Me Up Before You Go‐Go", + MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", + ISRC: "", + Artist: "Wham!", + ArtistMBID: "", + Album: "Make It Big", + AlbumMBID: "c143d542-48dc-446b-b523-1762da721638", + Duration: 0, }, })) }) @@ -464,14 +427,15 @@ var _ = Describe("listenBrainzAgent", func() { Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) Expect(resp).To(Equal([]agents.Song{ { - ID: "", - Name: "Take On Me", - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - ISRC: "", - Artists: []agents.Artist{{Name: "a‐ha"}}, - Album: "Hunting High and Low", - AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Duration: 0, + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artist: "a‐ha", + ArtistMBID: "", + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, }, })) }) diff --git a/adapters/listenbrainz/auth_router.go b/adapters/listenbrainz/auth_router.go index 1ff1a1495..7cb9eb16a 100644 --- a/adapters/listenbrainz/auth_router.go +++ b/adapters/listenbrainz/auth_router.go @@ -16,7 +16,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/utils/httpclient" ) type sessionKeysRepo interface { @@ -38,7 +37,9 @@ func NewRouter(ds model.DataStore) *Router { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() - hc := httpclient.New(consts.DefaultHttpClientTimeOut) + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc) return r } diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go index aae4fb51d..708f02f28 100644 --- a/adapters/listenbrainz/client.go +++ b/adapters/listenbrainz/client.go @@ -13,7 +13,6 @@ import ( "slices" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) @@ -22,12 +21,6 @@ const ( labsBase = "https://labs.api.listenbrainz.org/" ) -// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In -// (delta-seconds) on every response, including the 429, and never Retry-After. -func retryLaterErr(h http.Header) *agents.RetryLaterError { - return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))} -} - var ( ErrorNotFound = errors.New("listenbrainz: not found") ) @@ -181,9 +174,6 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en } defer resp.Body.Close() - if resp.StatusCode == http.StatusTooManyRequests { - return nil, retryLaterErr(resp.Header) - } decoder := json.NewDecoder(resp.Body) var response listenBrainzResponse @@ -195,10 +185,6 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en return nil, jsonErr } if response.Code != 0 && response.Code != 200 { - // LB also reports rate limiting as a body code, not only as an HTTP status. - if response.Code == http.StatusTooManyRequests { - return &response, retryLaterErr(resp.Header) - } return &response, &listenBrainzError{Code: response.Code, Message: response.Error} } @@ -225,9 +211,6 @@ func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint // On a 200 code, there is no code. Decode using using error message if it exists if resp.StatusCode != 200 { defer resp.Body.Close() - if resp.StatusCode == http.StatusTooManyRequests { - return nil, retryLaterErr(resp.Header) - } decoder := json.NewDecoder(resp.Body) var lbzError lbzHttpError diff --git a/adapters/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go index ec0b0ac11..319cf01ab 100644 --- a/adapters/listenbrainz/client_test.go +++ b/adapters/listenbrainz/client_test.go @@ -4,17 +4,13 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" "os" - "strings" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -465,73 +461,4 @@ var _ = Describe("client", func() { })) }) }) - - Describe("rate limiting", func() { - It("returns RetryLaterError with the header delay on 429", func() { - httpClient.Res = http.Response{ - StatusCode: 429, - Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}}, - Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), - } - _, err := client.validateToken(context.Background(), "token") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(3 * time.Second)) - }) - - It("returns RetryLaterError with zero delay when no header is present", func() { - httpClient.Res = http.Response{ - StatusCode: 429, - Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), - } - _, err := client.validateToken(context.Background(), "token") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - retry, _ := errors.AsType[*agents.RetryLaterError](err) - Expect(retry.RetryIn).To(BeZero()) - }) - - DescribeTable("caps absurd header values at one hour", - func(header string) { - httpClient.Res = http.Response{ - StatusCode: 429, - Header: http.Header{"X-Ratelimit-Reset-In": []string{header}}, - Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), - } - _, err := client.validateToken(context.Background(), "token") - retry, _ := errors.AsType[*agents.RetryLaterError](err) - Expect(retry.RetryIn).To(Equal(time.Hour)) - }, - Entry("a large value", "999999"), - Entry("a huge value", "99999999999"), - // Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s. - Entry("a value that overflows int64 nanoseconds", "18446744074"), - ) - - It("maps a body-level 429 sent with a non-429 status", func() { - httpClient.Res = http.Response{ - StatusCode: 200, - Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, - Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), - } - _, err := client.validateToken(context.Background(), "token") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(7 * time.Second)) - }) - - It("returns RetryLaterError on a 429 from makeGenericRequest", func() { - httpClient.Res = http.Response{ - StatusCode: 429, - Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}}, - Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), - } - _, err := client.getArtistUrl(context.Background(), "1") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(5 * time.Second)) - }) - }) }) diff --git a/api/.oasdiff-levels.txt b/api/.oasdiff-levels.txt deleted file mode 100644 index 1792adcb2..000000000 --- a/api/.oasdiff-levels.txt +++ /dev/null @@ -1 +0,0 @@ -response-property-enum-value-added INFO diff --git a/api/.vacuum.yaml b/api/.vacuum.yaml deleted file mode 100644 index bd31e23f8..000000000 --- a/api/.vacuum.yaml +++ /dev/null @@ -1,155 +0,0 @@ -extends: [[spectral:oas, recommended]] -rules: - # vacuum's `enumeration` function mis-resolves hyphenated `then.field` names, - # so the value check below targets `x-module` via `given` instead. - nd-operation-x-module-required: - description: Every operation belongs to exactly one capability module. - severity: error - given: $.paths[*][get,put,post,delete,patch] - then: - field: x-module - function: truthy - nd-operation-x-module: - description: Every operation's capability module is one of the known values. - severity: error - given: $.paths[*][get,put,post,delete,patch]['x-module'] - then: - function: enumeration - functionOptions: - values: - - core - - streaming - - download - - artwork - - lyrics - - transcoding - - annotations - - playback - - queue - - custom-tags - - grouping - - playlists - - smart-playlists - - sync - - events - - jukebox - - sharing - - radio - - admin - nd-operation-stability-level-required: - description: Every operation declares its stability level, which the breaking-change gate relies on. - severity: error - given: $.paths[*][get,put,post,delete,patch] - then: - field: x-stability-level - function: truthy - nd-operation-stability-level: - description: Every operation's stability level is alpha, beta, or stable. - severity: error - given: $.paths[*][get,put,post,delete,patch]['x-stability-level'] - then: - function: enumeration - functionOptions: - values: - - alpha - - beta - - stable - nd-operation-required-fields: - description: Operations need a stable operationId, summary, description and tags. - severity: error - given: $.paths[*][get,put,post,delete,patch] - then: - - field: operationId - function: truthy - - field: summary - function: truthy - - field: description - function: truthy - - field: tags - function: truthy - # Our schemas live in path/response files, not root components, so this - # walks every resolved `properties` map in the document via `$..` instead. - nd-schema-property-descriptions: - description: Every schema property is documented. - severity: error - given: $..properties[*] - then: - field: description - function: truthy - # patternProperties covers the full 4xx/5xx range; needs an explicit - # `properties` entry too, or `additionalProperties: false` rejects it. - nd-error-responses-are-problems: - description: 4xx and 5xx responses use application/problem+json. - severity: error - given: $.paths[*][*].responses - then: - function: schema - functionOptions: - forceValidationOnCurrentNode: true - schema: - type: object - patternProperties: - "^[45][0-9][0-9]$": - type: object - required: [content] - properties: - content: - type: object - properties: - application/problem+json: {} - required: [application/problem+json] - additionalProperties: false - # Same filter limitation applies here: "is this a list endpoint" is expressed - # as a JSON Schema if/then on the operation object instead of a `given` filter. - nd-list-endpoints-paginate: - description: List endpoints declare the shared offset and limit parameters. - severity: error - given: $.paths[*].get - then: - function: schema - functionOptions: - forceValidationOnCurrentNode: true - schema: - type: object - if: - required: [responses] - properties: - responses: - type: object - required: ['200'] - properties: - '200': - type: object - required: [content] - properties: - content: - type: object - required: [application/json] - properties: - application/json: - type: object - required: [schema] - properties: - schema: - type: object - required: [properties] - properties: - properties: - type: object - required: [items] - then: - required: [parameters] - properties: - parameters: - type: array - allOf: - - contains: - type: object - properties: - name: - const: offset - - contains: - type: object - properties: - name: - const: limit diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json deleted file mode 100644 index bfe794c71..000000000 --- a/api/bundled/openapi.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "Navidrome API", - "version": "1.0.0", - "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n", - "license": { - "name": "GPL-3.0", - "url": "https://www.gnu.org/licenses/gpl-3.0.html" - } - }, - "servers": [ - { - "url": "/api/v1" - } - ], - "tags": [ - { - "name": "server", - "description": "Server discovery and the published OpenAPI document." - } - ], - "paths": { - "/server": { - "get": { - "operationId": "getServerInfo", - "x-module": "core", - "x-stability-level": "alpha", - "tags": [ - "server" - ], - "summary": "Describe the server", - "description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n", - "responses": { - "200": { - "description": "Server description.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerInfo" - } - } - } - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/openapi.json": { - "get": { - "operationId": "getOpenAPISpecJSON", - "x-module": "core", - "x-stability-level": "alpha", - "tags": [ - "server" - ], - "summary": "Get the OpenAPI document (JSON)", - "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", - "responses": { - "200": { - "description": "The OpenAPI document.", - "headers": { - "ETag": { - "$ref": "#/components/headers/ETag" - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "OpenAPI 3.0 document." - } - } - } - }, - "304": { - "$ref": "#/components/responses/NotModified" - } - } - } - }, - "/openapi.yaml": { - "get": { - "operationId": "getOpenAPISpecYAML", - "x-module": "core", - "x-stability-level": "alpha", - "tags": [ - "server" - ], - "summary": "Get the OpenAPI document (YAML)", - "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", - "responses": { - "200": { - "description": "The OpenAPI document.", - "headers": { - "ETag": { - "$ref": "#/components/headers/ETag" - } - }, - "content": { - "application/yaml": { - "schema": { - "type": "object", - "description": "OpenAPI 3.0 document." - } - } - } - }, - "304": { - "$ref": "#/components/responses/NotModified" - } - } - } - } - }, - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - "description": "Short-lived access token minted from a device grant. Not yet applied to any operation." - } - }, - "schemas": { - "ServerInfo": { - "type": "object", - "description": "Public server description. Everything an add-server screen needs before login.", - "required": [ - "name", - "serverVersion", - "specVersion", - "setupRequired", - "loginMethods" - ], - "properties": { - "name": { - "type": "string", - "description": "Human-readable server product name." - }, - "serverVersion": { - "type": "string", - "description": "Version of the running server build." - }, - "specVersion": { - "type": "string", - "description": "Version of the OpenAPI document this server implements." - }, - "setupRequired": { - "type": "boolean", - "description": "True until the first admin user has been created." - }, - "loginMethods": { - "type": "array", - "description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.", - "items": { - "type": "string", - "enum": [ - "password" - ] - } - } - } - }, - "Problem": { - "type": "object", - "description": "RFC 9457 problem details, returned for every 4xx and 5xx response.", - "required": [ - "title", - "status", - "code" - ], - "properties": { - "type": { - "type": "string", - "description": "URI reference identifying the problem type. Omitted while the problem carries no semantics\nbeyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their\nown semantics get their own URI; switch on `code` instead.\n" - }, - "title": { - "type": "string", - "description": "Short human-readable summary, the same for all occurrences of this problem type." - }, - "status": { - "type": "integer", - "description": "HTTP status code of this response." - }, - "detail": { - "type": "string", - "description": "Human-readable explanation specific to this occurrence. Omitted for internal errors." - }, - "code": { - "type": "string", - "description": "Machine-readable error code, and the value clients switch on. New codes may be added.", - "enum": [ - "validation", - "unauthorized", - "forbidden", - "not_found", - "method_not_allowed", - "unavailable", - "internal" - ] - }, - "errors": { - "type": "array", - "description": "Per-field failures. Present only when `code` is `validation`.", - "items": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "ValidationError": { - "type": "object", - "description": "One field-level validation failure.", - "required": [ - "field", - "message" - ], - "properties": { - "field": { - "type": "string", - "description": "Name of the offending query parameter, path parameter, or body field (dotted for nested)." - }, - "message": { - "type": "string", - "description": "Why the value was rejected." - } - } - } - }, - "responses": { - "InternalError": { - "description": "Unexpected server failure. Details are in the server log.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "NotModified": { - "description": "Not modified.", - "headers": { - "ETag": { - "$ref": "#/components/headers/ETag" - } - } - } - }, - "headers": { - "ETag": { - "description": "Entity tag for `If-None-Match` revalidation.", - "schema": { - "type": "string" - } - } - } - } -} \ No newline at end of file diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml deleted file mode 100644 index f1ae95b76..000000000 --- a/api/bundled/openapi.yaml +++ /dev/null @@ -1,194 +0,0 @@ -openapi: 3.0.3 -info: - title: Navidrome API - version: 1.0.0 - description: | - Navidrome API v1. Spec-first, additive within v1. Clients discover implemented - capability modules through `GET /server` and never sniff versions. - - Enums are open: new values may be added to any enum within v1. Clients must - accept values they do not recognise instead of failing. - - Every operation declares `x-stability-level`: `alpha` operations may change or - disappear without notice, `beta` and `stable` operations only change additively. - A level is only ever raised, never lowered. - - `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods - in its `Allow` header. - license: - name: GPL-3.0 - url: https://www.gnu.org/licenses/gpl-3.0.html -servers: - - url: /api/v1 -tags: - - name: server - description: Server discovery and the published OpenAPI document. -paths: - /server: - get: - operationId: getServerInfo - x-module: core - x-stability-level: alpha - tags: [server] - summary: Describe the server - description: | - Returns the public server description. No authentication required. - Authenticated requests will additionally receive the implemented capability modules - once authentication is available. - responses: - '200': - description: Server description. - content: - application/json: - schema: - $ref: '#/components/schemas/ServerInfo' - '500': - $ref: '#/components/responses/InternalError' - /openapi.json: - get: - operationId: getOpenAPISpecJSON - x-module: core - x-stability-level: alpha - tags: [server] - summary: Get the OpenAPI document (JSON) - description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. - responses: - '200': - description: The OpenAPI document. - headers: - ETag: - $ref: '#/components/headers/ETag' - content: - application/json: - schema: - type: object - description: OpenAPI 3.0 document. - '304': - $ref: '#/components/responses/NotModified' - /openapi.yaml: - get: - operationId: getOpenAPISpecYAML - x-module: core - x-stability-level: alpha - tags: [server] - summary: Get the OpenAPI document (YAML) - description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. - responses: - '200': - description: The OpenAPI document. - headers: - ETag: - $ref: '#/components/headers/ETag' - content: - application/yaml: - schema: - type: object - description: OpenAPI 3.0 document. - '304': - $ref: '#/components/responses/NotModified' -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: Short-lived access token minted from a device grant. Not yet applied to any operation. - schemas: - ServerInfo: - type: object - description: Public server description. Everything an add-server screen needs before login. - required: - - name - - serverVersion - - specVersion - - setupRequired - - loginMethods - properties: - name: - type: string - description: Human-readable server product name. - serverVersion: - type: string - description: Version of the running server build. - specVersion: - type: string - description: Version of the OpenAPI document this server implements. - setupRequired: - type: boolean - description: True until the first admin user has been created. - loginMethods: - type: array - description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - items: - type: string - enum: - - password - Problem: - type: object - description: RFC 9457 problem details, returned for every 4xx and 5xx response. - required: - - title - - status - - code - properties: - type: - type: string - description: | - URI reference identifying the problem type. Omitted while the problem carries no semantics - beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their - own semantics get their own URI; switch on `code` instead. - title: - type: string - description: Short human-readable summary, the same for all occurrences of this problem type. - status: - type: integer - description: HTTP status code of this response. - detail: - type: string - description: Human-readable explanation specific to this occurrence. Omitted for internal errors. - code: - type: string - description: Machine-readable error code, and the value clients switch on. New codes may be added. - enum: - - validation - - unauthorized - - forbidden - - not_found - - method_not_allowed - - unavailable - - internal - errors: - type: array - description: Per-field failures. Present only when `code` is `validation`. - items: - $ref: '#/components/schemas/ValidationError' - ValidationError: - type: object - description: One field-level validation failure. - required: - - field - - message - properties: - field: - type: string - description: Name of the offending query parameter, path parameter, or body field (dotted for nested). - message: - type: string - description: Why the value was rejected. - responses: - InternalError: - description: Unexpected server failure. Details are in the server log. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - NotModified: - description: Not modified. - headers: - ETag: - $ref: '#/components/headers/ETag' - headers: - ETag: - description: Entity tag for `If-None-Match` revalidation. - schema: - type: string diff --git a/api/embed.go b/api/embed.go deleted file mode 100644 index 7e94c2362..000000000 --- a/api/embed.go +++ /dev/null @@ -1,35 +0,0 @@ -package api - -import ( - _ "embed" - "encoding/json" - "sync" -) - -//go:embed bundled/openapi.json -var specJSON []byte - -//go:embed bundled/openapi.yaml -var specYAML []byte - -func SpecJSON() []byte { - return specJSON -} - -func SpecYAML() []byte { - return specYAML -} - -var specVersion = sync.OnceValue(func() string { - var doc struct { - Info struct { - Version string `json:"version"` - } `json:"info"` - } - _ = json.Unmarshal(SpecJSON(), &doc) - return doc.Info.Version -}) - -func SpecVersion() string { - return specVersion() -} diff --git a/api/embed_test.go b/api/embed_test.go deleted file mode 100644 index ebd5a7d1d..000000000 --- a/api/embed_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package api_test - -import ( - "os" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/navidrome/navidrome/api" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "gopkg.in/yaml.v3" -) - -var _ = Describe("Bundled spec", func() { - It("embeds a valid OpenAPI 3 document", func() { - doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) - Expect(err).ToNot(HaveOccurred()) - Expect(doc.Validate(GinkgoT().Context())).To(Succeed()) - Expect(doc.Paths.Find("/server")).ToNot(BeNil()) - }) - - It("embeds the YAML variant", func() { - var doc map[string]any - Expect(yaml.Unmarshal(api.SpecYAML(), &doc)).To(Succeed()) - Expect(doc).To(HaveKey("paths")) - }) - - It("reports the version from the bundle, matching the source root document", func() { - src, err := os.ReadFile("api/openapi/openapi.yaml") - Expect(err).ToNot(HaveOccurred()) - var root struct { - Info struct{ Version string } `yaml:"info"` - } - Expect(yaml.Unmarshal(src, &root)).To(Succeed()) - Expect(api.SpecVersion()).To(Equal(root.Info.Version)) - Expect(api.SpecVersion()).ToNot(BeEmpty()) - }) -}) diff --git a/api/openapi/components/headers/ETag.yaml b/api/openapi/components/headers/ETag.yaml deleted file mode 100644 index 0f64e6792..000000000 --- a/api/openapi/components/headers/ETag.yaml +++ /dev/null @@ -1,3 +0,0 @@ -description: Entity tag for `If-None-Match` revalidation. -schema: - type: string diff --git a/api/openapi/components/parameters/limit.yaml b/api/openapi/components/parameters/limit.yaml deleted file mode 100644 index 9bef4fc8b..000000000 --- a/api/openapi/components/parameters/limit.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: limit -in: query -description: Maximum number of items to return. -required: false -schema: - type: integer - minimum: 1 - maximum: 2000 - default: 100 diff --git a/api/openapi/components/parameters/offset.yaml b/api/openapi/components/parameters/offset.yaml deleted file mode 100644 index 9145d6eaa..000000000 --- a/api/openapi/components/parameters/offset.yaml +++ /dev/null @@ -1,8 +0,0 @@ -name: offset -in: query -description: Zero-based index of the first item to return. -required: false -schema: - type: integer - minimum: 0 - default: 0 diff --git a/api/openapi/components/responses/BadRequest.yaml b/api/openapi/components/responses/BadRequest.yaml deleted file mode 100644 index 1a6e0b657..000000000 --- a/api/openapi/components/responses/BadRequest.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: The request is malformed or fails validation. -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/Forbidden.yaml b/api/openapi/components/responses/Forbidden.yaml deleted file mode 100644 index 6259185ea..000000000 --- a/api/openapi/components/responses/Forbidden.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: The caller is authenticated but not allowed to do this. -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/InternalError.yaml b/api/openapi/components/responses/InternalError.yaml deleted file mode 100644 index 20e654d44..000000000 --- a/api/openapi/components/responses/InternalError.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: Unexpected server failure. Details are in the server log. -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/NotFound.yaml b/api/openapi/components/responses/NotFound.yaml deleted file mode 100644 index 6083a5cd1..000000000 --- a/api/openapi/components/responses/NotFound.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: No such resource or endpoint. -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/NotModified.yaml b/api/openapi/components/responses/NotModified.yaml deleted file mode 100644 index 36bc1a61c..000000000 --- a/api/openapi/components/responses/NotModified.yaml +++ /dev/null @@ -1,4 +0,0 @@ -description: Not modified. -headers: - ETag: - $ref: ../headers/ETag.yaml diff --git a/api/openapi/components/responses/Unauthorized.yaml b/api/openapi/components/responses/Unauthorized.yaml deleted file mode 100644 index 0209f4dd9..000000000 --- a/api/openapi/components/responses/Unauthorized.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: Missing, invalid, or expired credentials. -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/schemas/ListMeta.yaml b/api/openapi/components/schemas/ListMeta.yaml deleted file mode 100644 index 6321e0588..000000000 --- a/api/openapi/components/schemas/ListMeta.yaml +++ /dev/null @@ -1,13 +0,0 @@ -type: object -description: Pagination metadata carried by every list response. -required: [total, offset, limit] -properties: - total: - type: integer - description: Total number of items matching the request, ignoring pagination. - offset: - type: integer - description: Zero-based index of the first returned item. - limit: - type: integer - description: Maximum number of items in this page. diff --git a/api/openapi/components/schemas/Problem.yaml b/api/openapi/components/schemas/Problem.yaml deleted file mode 100644 index 0fd36d4b1..000000000 --- a/api/openapi/components/schemas/Problem.yaml +++ /dev/null @@ -1,35 +0,0 @@ -type: object -description: RFC 9457 problem details, returned for every 4xx and 5xx response. -required: [title, status, code] -properties: - type: - type: string - description: | - URI reference identifying the problem type. Omitted while the problem carries no semantics - beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their - own semantics get their own URI; switch on `code` instead. - title: - type: string - description: Short human-readable summary, the same for all occurrences of this problem type. - status: - type: integer - description: HTTP status code of this response. - detail: - type: string - description: Human-readable explanation specific to this occurrence. Omitted for internal errors. - code: - type: string - description: Machine-readable error code, and the value clients switch on. New codes may be added. - enum: - - validation - - unauthorized - - forbidden - - not_found - - method_not_allowed - - unavailable - - internal - errors: - type: array - description: Per-field failures. Present only when `code` is `validation`. - items: - $ref: ./ValidationError.yaml diff --git a/api/openapi/components/schemas/ServerInfo.yaml b/api/openapi/components/schemas/ServerInfo.yaml deleted file mode 100644 index 8906924eb..000000000 --- a/api/openapi/components/schemas/ServerInfo.yaml +++ /dev/null @@ -1,22 +0,0 @@ -type: object -description: Public server description. Everything an add-server screen needs before login. -required: [name, serverVersion, specVersion, setupRequired, loginMethods] -properties: - name: - type: string - description: Human-readable server product name. - serverVersion: - type: string - description: Version of the running server build. - specVersion: - type: string - description: Version of the OpenAPI document this server implements. - setupRequired: - type: boolean - description: True until the first admin user has been created. - loginMethods: - type: array - description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - items: - type: string - enum: [password] diff --git a/api/openapi/components/schemas/ValidationError.yaml b/api/openapi/components/schemas/ValidationError.yaml deleted file mode 100644 index 8a1cbc4f9..000000000 --- a/api/openapi/components/schemas/ValidationError.yaml +++ /dev/null @@ -1,10 +0,0 @@ -type: object -description: One field-level validation failure. -required: [field, message] -properties: - field: - type: string - description: Name of the offending query parameter, path parameter, or body field (dotted for nested). - message: - type: string - description: Why the value was rejected. diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml deleted file mode 100644 index 73cbb7b27..000000000 --- a/api/openapi/openapi.yaml +++ /dev/null @@ -1,39 +0,0 @@ -openapi: 3.0.3 -info: - title: Navidrome API - version: 1.0.0 - description: | - Navidrome API v1. Spec-first, additive within v1. Clients discover implemented - capability modules through `GET /server` and never sniff versions. - - Enums are open: new values may be added to any enum within v1. Clients must - accept values they do not recognise instead of failing. - - Every operation declares `x-stability-level`: `alpha` operations may change or - disappear without notice, `beta` and `stable` operations only change additively. - A level is only ever raised, never lowered. - - `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods - in its `Allow` header. - license: - name: GPL-3.0 - url: https://www.gnu.org/licenses/gpl-3.0.html -servers: - - url: /api/v1 -tags: - - name: server - description: Server discovery and the published OpenAPI document. -paths: - /server: - $ref: ./paths/server.yaml - /openapi.json: - $ref: ./paths/openapi.yaml#/json - /openapi.yaml: - $ref: ./paths/openapi.yaml#/yaml -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: Short-lived access token minted from a device grant. Not yet applied to any operation. diff --git a/api/openapi/paths/openapi.yaml b/api/openapi/paths/openapi.yaml deleted file mode 100644 index 3c25dc8b7..000000000 --- a/api/openapi/paths/openapi.yaml +++ /dev/null @@ -1,42 +0,0 @@ -json: - get: - operationId: getOpenAPISpecJSON - x-module: core - x-stability-level: alpha - tags: [server] - summary: Get the OpenAPI document (JSON) - description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. - responses: - '200': - description: The OpenAPI document. - headers: - ETag: - $ref: ../components/headers/ETag.yaml - content: - application/json: - schema: - type: object - description: OpenAPI 3.0 document. - '304': - $ref: ../components/responses/NotModified.yaml -yaml: - get: - operationId: getOpenAPISpecYAML - x-module: core - x-stability-level: alpha - tags: [server] - summary: Get the OpenAPI document (YAML) - description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. - responses: - '200': - description: The OpenAPI document. - headers: - ETag: - $ref: ../components/headers/ETag.yaml - content: - application/yaml: - schema: - type: object - description: OpenAPI 3.0 document. - '304': - $ref: ../components/responses/NotModified.yaml diff --git a/api/openapi/paths/server.yaml b/api/openapi/paths/server.yaml deleted file mode 100644 index 1f881dbb1..000000000 --- a/api/openapi/paths/server.yaml +++ /dev/null @@ -1,19 +0,0 @@ -get: - operationId: getServerInfo - x-module: core - x-stability-level: alpha - tags: [server] - summary: Describe the server - description: | - Returns the public server description. No authentication required. - Authenticated requests will additionally receive the implemented capability modules - once authentication is available. - responses: - '200': - description: Server description. - content: - application/json: - schema: - $ref: ../components/schemas/ServerInfo.yaml - '500': - $ref: ../components/responses/InternalError.yaml diff --git a/cmd/artwork.go b/cmd/artwork.go deleted file mode 100644 index 72685678e..000000000 --- a/cmd/artwork.go +++ /dev/null @@ -1,1022 +0,0 @@ -package cmd - -import ( - "cmp" - "context" - "errors" - "fmt" - "io" - "os" - "slices" - "strconv" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins" - "github.com/navidrome/navidrome/utils/slice" - "github.com/spf13/cobra" -) - -var explainLive bool - -// Only one subcommand runs per invocation, so reprocess and cancel bind the same flag targets. -var ( - artworkKinds []string - artworkSources []string - artworkPriorities []string - artworkAll bool - artworkDryRun bool - artworkYes bool -) - -func init() { - artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false, - "walk the chain again now, performing real external lookups, instead of reporting the "+ - "stored trace of the last resolution; also initializes plugin agents, which may open "+ - "external connections") - artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, - "kinds to reprocess ("+kindPrefixes(artwork.ReprocessKinds)+"); repeatable") - artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil, - "only items currently resolved from these sources (e.g. folder, external:deezer, absent, "+ - "or failed for the absent ones that gave up)") - artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind") - artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, - "report what would be queued and exit without queueing") - artworkReprocessCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt") - artworkCancelCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, - "kinds to cancel ("+kindPrefixes(artwork.RefreshableKinds)+"); repeatable") - artworkCancelCmd.Flags().StringSliceVar(&artworkPriorities, "priority", nil, - "only rows queued at these priorities ("+priorityNames()+"); repeatable") - artworkCancelCmd.Flags().BoolVar(&artworkAll, "all", false, "cancel every kind at every priority") - artworkCancelCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, - "report what would be cancelled and exit without cancelling") - artworkCancelCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt") - artworkCmd.AddCommand(artworkExplainCmd) - artworkCmd.AddCommand(artworkRefreshCmd) - artworkCmd.AddCommand(artworkReprocessCmd) - artworkCmd.AddCommand(artworkCancelCmd) - artworkCmd.AddCommand(artworkStatusCmd) - rootCmd.AddCommand(artworkCmd) -} - -var artworkCmd = &cobra.Command{ - Use: "artwork", - Short: "Inspect and re-resolve artwork", -} - -var artworkExplainCmd = &cobra.Command{ - Use: "explain [] ", - Short: "Explain why an item's artwork resolved the way it did", - Long: "Explain why an item's artwork resolved the way it did.\n\n" + - "The item can be given as a bare id, a full artwork id (e.g. al-), or a pair.\n" + - " is one of: " + kindPrefixes(explainKinds) + ".\n" + - "A disc artwork id is the album id and the disc number, joined by a colon: :2", - Args: cobra.RangeArgs(1, 2), - Run: func(cmd *cobra.Command, args []string) { - runExplain(cmd.Context(), args) - }, -} - -var artworkRefreshCmd = &cobra.Command{ - Use: "refresh [] ...", - Short: "Clear an item's artwork state and re-resolve it", - Long: "Clear an item's artwork state and re-resolve it.\n\n" + - "Each item can be given as a bare id, a full artwork id (e.g. al-), or a shared\n" + - " ... leader. is one of: " + kindPrefixes(artwork.RefreshableKinds) + ".", - Args: cobra.MinimumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - runRefresh(cmd.Context(), args) - }, -} - -var artworkReprocessCmd = &cobra.Command{ - Use: "reprocess", - Short: "Re-enqueue artwork in bulk, by kind and/or by the source it currently resolves from", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - runReprocess(cmd.Context()) - }, -} - -var artworkCancelCmd = &cobra.Command{ - Use: "cancel", - Short: "Cancel pending artwork work in bulk, by kind and/or queue priority", - Long: "Cancel pending artwork work in bulk, by kind and/or queue priority.\n\n" + - "Only the queue is touched: resolved artwork and the state behind `artwork explain` are\n" + - "left alone, and the trace of why a cancelled item last failed goes with its queue row.\n\n" + - "Work already picked up is not interrupted, and an item with no artwork yet can be\n" + - "queued again by the hourly re-check. The selection is applied again when you confirm,\n" + - "so anything queued after the preview is cancelled too. Use it to call off a bulk\n" + - "reprocess, not to stop the worker.", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - runCancel(cmd.Context()) - }, -} - -var artworkStatusCmd = &cobra.Command{ - Use: "status", - Short: "Report the artwork queue, where artwork resolves from, and the config state", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - runStatus(cmd.Context()) - }, -} - -func runStatus(ctx context.Context) { - defer db.Init(ctx)() - ds, ctx := getAdminContext(ctx) - - rep, err := collectStatus(ctx, ds) - if err != nil { - log.Fatal(ctx, err) - } - fmt.Print(formatStatus(rep)) -} - -type sourceCount struct { - kind model.Kind - source string - count int64 -} - -// absentCount partitions a kind's absent states: noImage was answered, failed gave up. -type absentCount struct { - kind model.Kind - noImage int64 - failed int64 -} - -type statusReport struct { - queue []model.ArtworkQueueStat - sources []sourceCount - absent []absentCount - inputs []artwork.FingerprintInput - stored string - current string -} - -func (r statusReport) queueTotal() int64 { return queueTotal(r.queue) } - -func queueTotal(stats []model.ArtworkQueueStat) int64 { - var n int64 - for _, s := range stats { - n += s.Count - } - return n -} - -func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) { - q := ds.ArtworkQueue() - var rep statusReport - var err error - if rep.queue, err = q.CountQueued(ctx, nil, nil); err != nil { - return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err) - } - - for _, k := range artwork.ReprocessKinds { - sources, err := q.SourcesInUse(ctx, k) - if err != nil { - return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) - } - slices.Sort(sources) - for _, s := range sources { - n, err := q.CountBySource(ctx, k, []string{s}) - if err != nil { - return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err) - } - rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n}) - // An absent state is exactly a row with no source, so it needs no second query. - if s == "" { - failed, err := q.CountBySource(ctx, k, []string{model.ArtworkSourceFailed}) - if err != nil { - return rep, fmt.Errorf("counting failed %s artwork: %w", k, err) - } - rep.absent = append(rep.absent, absentCount{kind: k, noImage: n - failed, failed: failed}) - } - } - } - - rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs() - if rep.stored, err = ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, ""); err != nil { - return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err) - } - return rep, nil -} - -func formatStatus(rep statusReport) string { - var sb strings.Builder - w := newTabWriter(&sb) - - fmt.Fprintln(w, "Queue") - if len(rep.queue) == 0 { - fmt.Fprintln(w, " (empty)") - } else { - printQueueStats(w, rep.queue, rep.queueTotal(), "ITEMS", " ") - } - - fmt.Fprintln(w, "\nSources") - fmt.Fprintln(w, " KIND\tSOURCE\tITEMS") - for _, s := range rep.sources { - fmt.Fprintf(w, " %s\t%s\t%d\n", s.kind, displaySource(s.source), s.count) - } - - fmt.Fprintln(w, "\nAbsent (resolved, no image found)") - fmt.Fprintln(w, " KIND\tNO IMAGE\tFAILED") - for _, a := range rep.absent { - fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.noImage, a.failed) - } - fmt.Fprintln(w, " (nothing retries these; 'artwork reprocess --source absent' retries both columns)") - fmt.Fprintln(w, " (failed = gave up rather than being answered, so the ones most likely to resolve;\n"+ - " 'artwork reprocess --source failed' retries just those)") - - fmt.Fprintln(w, "\nConfig") - fmt.Fprintf(w, " State:\t%s\n", configState(rep)) - fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)")) - fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current) - if len(rep.inputs) > 0 { - fmt.Fprintln(w, " Fingerprint inputs (changing any of these makes the stored artwork stale):") - for _, in := range rep.inputs { - fmt.Fprintf(w, " %s:\t%s\n", in.Name, in.Value) - } - } - - w.Flush() - return sb.String() -} - -func configState(rep statusReport) string { - if rep.stored != rep.current { - return "fingerprint changed — stored artwork keeps the old resolution; " + - "run 'artwork reprocess --all' to apply it" - } - return "up to date" -} - -// printQueueStats writes the shared queue breakdown; the caller owns the tab writer and flushes it. -func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) { - fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader) - for _, s := range stats { - fmt.Fprintf(w, "%s%s\t%s\t%d\n", indent, kindName(s.ItemKind), priorityName(s.Priority), s.Count) - } - fmt.Fprintf(w, "%sTOTAL\t\t%d\n", indent, total) -} - -func kindName(prefix string) string { - if k, ok := model.ParseKind(prefix); ok { - return k.String() - } - return prefix -} - -type artworkPriority struct { - name string - value int -} - -// knownPriorities is the one listing behind both the name and the parse, so they cannot drift. -var knownPriorities = []artworkPriority{ - {"bump", model.ArtworkPriorityBump}, - {"scan", model.ArtworkPriorityScan}, - {"recheck", model.ArtworkPriorityRecheck}, - {"backfill", model.ArtworkPriorityBackfill}, -} - -// priorityName falls back to the number: a row written by a newer version still has to print. -func priorityName(p int) string { - for _, ap := range knownPriorities { - if ap.value == p { - return ap.name - } - } - return strconv.Itoa(p) -} - -func priorityNames() string { - return strings.Join(slice.Map(knownPriorities, func(ap artworkPriority) string { return ap.name }), ", ") -} - -func parseArtworkPriority(s string) (int, error) { - for _, ap := range knownPriorities { - if ap.name == s { - return ap.value, nil - } - } - return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames()) -} - -func runReprocess(ctx context.Context) { - kinds, err := selectedKinds(artworkKinds, artworkSources, artworkAll) - if err != nil { - log.Fatal(ctx, err) - } - - defer db.Init(ctx)() - ds, ctx := getAdminContext(ctx) - - // Only a kind that can reach an agent needs the count, and loading a plugin creates its - // services. A preview must not reach the network, so init never runs here. - var imageAgents artwork.ImageAgentCount - if needsImageAgents(kinds) { - mgr := loadPluginAgents(ctx, false) - defer func() { _ = mgr.Stop() }() - imageAgents = artwork.NewImageAgentCount(agents.GetAgents(ds, mgr)) - } - - if err := reprocessArtwork(ctx, ds, kinds, repositorySources(artworkSources), imageAgents, - artworkDryRun, confirmUnlessYes(artworkYes, os.Stdin, "re-resolve"), os.Stdout); err != nil { - log.Fatal(ctx, err) - } -} - -func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) { - // A source filter on its own is already a complete selection, so it does not also need a kind. - if all || (len(kinds) == 0 && len(sources) > 0) { - return artwork.ReprocessKinds, nil - } - if len(kinds) == 0 { - return nil, fmt.Errorf("no selector given: pass --kind, --source or --all") - } - return parseAll(kinds, func(s string) (model.Kind, error) { - return parseArtworkKind(s, artwork.ReprocessKinds) - }) -} - -// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI, and -// failedSource the subset of it that gave up rather than being answered. -const ( - absentSource = "absent" - failedSource = "failed" -) - -func repositorySources(sources []string) []string { - return slice.Map(sources, func(s string) string { - switch s { - case absentSource: - return "" - case failedSource: - return model.ArtworkSourceFailed - } - return s - }) -} - -func displaySource(s string) string { - if s == model.ArtworkSourceFailed { - return failedSource - } - return cmp.Or(s, absentSource) -} - -type confirmFunc func(out io.Writer, total, external int64) bool - -func confirmUnlessYes(yes bool, in io.Reader, verb string) confirmFunc { - if yes { - return func(io.Writer, int64, int64) bool { return true } - } - return promptConfirm(in, verb) -} - -// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the -// plugin agents it counts are only the ones this process managed to load. -func externalEstimate(n int64) string { - if n == 0 { - return "none" - } - return fmt.Sprintf("~%d estimated (plugin agents counted only when they load; local hits may need fewer)", n) -} - -func externalLookupLine(n int64) string { - return fmt.Sprintf("External lookups: %s.", externalEstimate(n)) -} - -// loadPluginAgents loads the plugins named in Agents, so the CLI resolves through the same agents a -// running server would. A load failure is reported, not fatal: the built-in agents still answer. -func loadPluginAgents(ctx context.Context, runInit bool) *plugins.Manager { - mgr := getPluginManager() - if err := mgr.LoadPlugins(ctx, configuredAgents(), runInit); err != nil { - log.Warn(ctx, "Could not load plugins; plugin-provided agents will be missing", err) - } - return mgr -} - -// needsImageAgents asks exactly what ExternalLookupsPerItem asks, so the gate cannot disagree with -// the estimate it guards. Playlists count: their generated grid resolves album art through agents. -func needsImageAgents(kinds []model.Kind) bool { - return slices.ContainsFunc(kinds, artwork.MayFetchExternal) -} - -// configuredAgents names the agents in priority order; one absent from it can never supply an image. -func configuredAgents() []string { - var names []string - for name := range strings.SplitSeq(conf.Server.Agents, ",") { - if name = strings.TrimSpace(name); name != "" { - names = append(names, name) - } - } - return names -} - -func promptConfirm(in io.Reader, verb string) confirmFunc { - return func(out io.Writer, total, external int64) bool { - var cost string - if external > 0 { - cost = fmt.Sprintf(" %s", externalLookupLine(external)) - } - fmt.Fprintf(out, "\nThis will %s %d items.%s Continue? [y/N] ", verb, total, cost) - var answer string - if _, err := fmt.Fscanln(in, &answer); err != nil { - return false - } - answer = strings.ToLower(strings.TrimSpace(answer)) - return answer == "y" || answer == "yes" - } -} - -// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when -// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only. -func validateSources(ctx context.Context, q model.ArtworkQueueRepository, sources []string) error { - if len(sources) == 0 { - return nil - } - var inUse []string - for _, k := range artwork.ReprocessKinds { - found, err := q.SourcesInUse(ctx, k) - if err != nil { - return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) - } - inUse = slice.Unique(append(inUse, found...)) - } - var unknown []string - for _, s := range sources { - // The reserved absent and failed sources are valid even when nothing currently matches them. - if s != "" && s != model.ArtworkSourceFailed && !slices.Contains(inUse, s) { - unknown = append(unknown, displaySource(s)) - } - } - if len(unknown) == 0 { - return nil - } - // failed is accepted but never stored, so listing only what is in use would hide it. - valid := append(slice.Map(inUse, displaySource), failedSource) - slices.Sort(valid) - return fmt.Errorf("no artwork resolves from %s; sources in use: %s", - strings.Join(unknown, ", "), cmp.Or(strings.Join(valid, ", "), "(none)")) -} - -// reprocessArtwork previews from CountBySource — rows matched — then reports what EnqueueBySource -// actually inserted; the two differ because an already-queued row is left untouched. -func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string, - imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error { - q := ds.ArtworkQueue() - if err := validateSources(ctx, q, sources); err != nil { - return err - } - - // Derived from what actually drives the queries, so a filter added to this signature cannot - // silently keep stamping the fingerprint for a partial run. - markApplied := func() error { - if len(sources) > 0 || len(kinds) < len(artwork.ReprocessKinds) { - return nil - } - if err := artwork.MarkConfigApplied(ctx, ds); err != nil { - return fmt.Errorf("recording the applied artwork config: %w", err) - } - return nil - } - - matched := make([]int64, len(kinds)) - var total, external int64 - for i, k := range kinds { - n, err := q.CountBySource(ctx, k, sources) - if err != nil { - return fmt.Errorf("counting %s artwork: %w", k, err) - } - matched[i] = n - total += n - external += n * artwork.ExternalLookupsPerItem(k, imageAgents) - } - printReprocessPreview(out, kinds, matched, total, external, sources) - - switch { - case dryRun: - fmt.Fprintln(out, "\nDry run: nothing was queued.") - return nil - case total == 0: - // An empty match set still leaves nothing resolved under the old config. - fmt.Fprintln(out, "Nothing was queued.") - return markApplied() - case !confirm(out, total, external): - fmt.Fprintln(out, "Aborted: nothing was queued.") - return nil - } - - var queued int64 - for i, k := range kinds { - if matched[i] == 0 { - continue - } - n, err := q.EnqueueBySource(ctx, k, sources, model.ArtworkPriorityRecheck) - if err != nil { - return fmt.Errorf("queueing %s artwork: %w", k, err) - } - queued += n - fmt.Fprintf(out, "%s: %d queued\n", k, n) - } - fmt.Fprintf(out, "Queued %d of %d matched items.\n", queued, total) - if skipped := total - queued; skipped > 0 { - fmt.Fprintf(out, "Already queued, left unchanged: %d (priority and retry backoff untouched).\n", skipped) - } - return markApplied() -} - -func runCancel(ctx context.Context) { - kinds, priorities, err := cancelSelection(artworkKinds, artworkPriorities, artworkAll) - if err != nil { - log.Fatal(ctx, err) - } - - defer db.Init(ctx)() - ds, ctx := getAdminContext(ctx) - - if err := cancelArtwork(ctx, ds, kinds, priorities, artworkDryRun, - confirmUnlessYes(artworkYes, os.Stdin, "cancel"), os.Stdout); err != nil { - log.Fatal(ctx, err) - } -} - -// cancelSelection leaves --all as the empty filter the repository reads as "every one", so a row -// whose kind this build does not know still gets cancelled. -func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int, error) { - if all { - return nil, nil, nil - } - if len(kinds) == 0 && len(priorities) == 0 { - return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all") - } - // RefreshableKinds, not ReprocessKinds: media files are queued, so --kind must reach them. - outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) { - return parseArtworkKind(s, artwork.RefreshableKinds) - }) - if err != nil { - return nil, nil, err - } - outPriorities, err := parseAll(priorities, parseArtworkPriority) - if err != nil { - return nil, nil, err - } - return outKinds, outPriorities, nil -} - -// parseAll drops repeats: a doubled selector would overstate the total the operator confirms. -func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T, error) { - out := make([]T, 0, len(values)) - for _, v := range values { - parsed, err := parse(v) - if err != nil { - return nil, err - } - out = append(out, parsed) - } - return slice.Unique(out), nil -} - -func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int, - dryRun bool, confirm confirmFunc, out io.Writer) error { - q := ds.ArtworkQueue() - matched, err := q.CountQueued(ctx, kinds, priorities) - if err != nil { - return fmt.Errorf("counting queued artwork: %w", err) - } - total := queueTotal(matched) - w := newTabWriter(out) - printQueueStats(w, matched, total, "MATCHED", "") - w.Flush() - - switch { - case total == 0: - fmt.Fprintln(out, "\nNothing matches this selection.") - return nil - case dryRun: - fmt.Fprintln(out, "\nDry run: nothing was cancelled.") - return nil - case !confirm(out, total, 0): - fmt.Fprintln(out, "Aborted: nothing was cancelled.") - return nil - } - - cancelled, err := q.PurgeQueued(ctx, kinds, priorities) - if err != nil { - return fmt.Errorf("cancelling queued artwork: %w", err) - } - // Count and delete are separate statements, so a drain in between makes these two differ. - fmt.Fprintf(out, "Cancelled %d of %d matched items.\n", cancelled, total) - return nil -} - -// printReprocessPreview also states the external estimate, which --dry-run must show because it -// skips the prompt that would otherwise carry it. -func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, total, external int64, sources []string) { - w := newTabWriter(out) - shown := slice.Map(sources, displaySource) - fmt.Fprintf(w, "Sources:\t%s\n\n", cmp.Or(strings.Join(shown, ", "), "(any)")) - fmt.Fprintln(w, "KIND\tMATCHED") - for i, k := range kinds { - fmt.Fprintf(w, "%s\t%d\n", k, matched[i]) - } - fmt.Fprintf(w, "TOTAL\t%d\n", total) - w.Flush() - - fmt.Fprintf(out, "\n%s\n", externalLookupLine(external)) - if total == 0 { - fmt.Fprintln(out, "\nNothing matches this selection.") - } -} - -func runRefresh(ctx context.Context, args []string) { - defer db.Init(ctx)() - ds, ctx := getAdminContext(ctx) - - targets, failures, err := resolveArtworkTargets(ctx, ds, args, artwork.RefreshableKinds) - if err != nil { - log.Fatal(ctx, err) - } - for _, f := range failures { - log.Error(ctx, "Skipping unresolved item", f) - } - failed := refreshItems(ctx, ds, targets, os.Stdout) + len(failures) - if failed > 0 { - log.Fatal(ctx, "Failed to refresh artwork", "failed", failed, "total", len(targets)+len(failures)) - } -} - -// refreshItems keeps going after a failure — the items are independent — and returns how many failed. -func refreshItems(ctx context.Context, ds model.DataStore, targets []model.ArtworkID, out io.Writer) int { - var failed int - for _, t := range targets { - kind, id := t.Kind, t.ID - // artwork.Refresh would happily queue an id that does not exist, orphaning a queue row. - if _, err := artwork.ItemName(ctx, ds, kind, id); err != nil { - log.Error(ctx, "Item not found", "kind", kind, "id", id, err) - failed++ - continue - } - if err := artwork.Refresh(ctx, ds, kind, id); err != nil { - log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) - failed++ - continue - } - fmt.Fprintf(out, "%s/%s: queued\n", kind.Prefix(), id) - } - return failed -} - -// explainKinds is every kind explain accepts: it reports stored state and config too, so a kind -// with no chain to walk still has something to answer with. -var explainKinds = []model.Kind{ - model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, - model.KindMediaFileArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, -} - -func kindPrefixes(kinds []model.Kind) string { - return strings.Join(model.KindPrefixes(kinds), ", ") -} - -func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) { - kind, ok := model.ParseKind(s) - if ok && slices.Contains(valid, kind) { - return kind, nil - } - return kind, invalidKindErr(s, valid) -} - -func invalidKindErr(s string, valid []model.Kind) error { - return fmt.Errorf("invalid kind %q, expected one of: %s", s, kindPrefixes(valid)) -} - -// resolveArtworkTargets resolves explain/refresh positional args into artwork ids, accepting a -// shared " ..." leader or self-describing args (a bare id, or a full artwork id). A -// self-describing arg that cannot be resolved is returned as a failure rather than aborting the -// batch, so refresh can process the resolvable ids; a malformed leader is a usage error. -func resolveArtworkTargets(ctx context.Context, ds model.DataStore, args []string, valid []model.Kind) ([]model.ArtworkID, []error, error) { - if kind, ok := model.ParseKind(args[0]); ok && len(args) > 1 { - if !slices.Contains(valid, kind) { - return nil, nil, invalidKindErr(args[0], valid) - } - return slice.Map(args[1:], func(id string) model.ArtworkID { - return model.ArtworkID{Kind: kind, ID: id} - }), nil, nil - } - var targets []model.ArtworkID - var failures []error - for _, arg := range args { - target, err := artworkKindAndID(ctx, ds, arg) - if err == nil && !slices.Contains(valid, target.Kind) { - err = invalidKindErr(target.Kind.Prefix(), valid) - } - if err != nil { - failures = append(failures, err) - continue - } - targets = append(targets, target) - } - return targets, failures, nil -} - -// artworkKindAndID resolves one self-describing argument: a full artwork id (al-) takes its kind -// from the prefix, a bare id is looked up. Entity ids never start with "-", so no collision. -func artworkKindAndID(ctx context.Context, ds model.DataStore, arg string) (model.ArtworkID, error) { - if artID, err := model.ParseArtworkID(arg); err == nil && artID.ID != "" { - return model.ArtworkID{Kind: artID.Kind, ID: artID.ID}, nil - } - kind, err := model.GetEntityKindByID(ctx, ds, arg) - if errors.Is(err, model.ErrNotFound) { - return model.ArtworkID{}, fmt.Errorf("could not determine kind for %q; pass an explicit ", arg) - } - if err != nil { - return model.ArtworkID{}, err - } - return model.ArtworkID{Kind: kind, ID: arg}, nil -} - -// explainAgents accounts for every configured agent: one the CLI cannot construct (a plugin, or a -// built-in missing its credentials) never reaches the Chain, so the raw list alone overstates it. -func explainAgents(configured string, available []string) string { - if strings.TrimSpace(configured) == "" { - return "(none)" - } - var unavailable bool - names := slice.Map(strings.Split(configured, ","), func(name string) string { - name = strings.TrimSpace(name) - if slices.Contains(available, name) { - return name - } - unavailable = true - return name + "*" - }) - line := strings.Join(names, ", ") - if unavailable { - line += " (* not available to the CLI)" - } - return line -} - -// availableImageAgents names the agents that can actually supply an image for kind. -func availableImageAgents(ds model.DataStore, mgr *plugins.Manager, kind model.Kind) []string { - ag := agents.GetAgents(ds, mgr) - if kind == model.KindArtistArtwork { - return slice.Map(ag.ArtistImageAgents(), func(a agents.ArtistImageAgent) string { return a.Name }) - } - return slice.Map(ag.AlbumImageAgents(), func(a agents.AlbumImageAgent) string { return a.Name }) -} - -// explainResult states the verdict of the walk. A skipped or failed external tier, or a local -// candidate that would not open, leaves the outcome unknown: nothing observed that there is no artwork. -func explainResult(source string, steps []artwork.TraceStep) string { - if source != "" { - for _, s := range steps { - if s.Outcome == artwork.OutcomeHit { - break - } - // An external winner discards the earlier error, so the resolver settles it with no retry. - if s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix) && - !strings.HasPrefix(source, artwork.ExternalPrefix) { - return "resolved from " + source + - " (indeterminate: a higher-priority external lookup failed; this may resolve differently on a retry)" - } - } - return "resolved from " + source - } - for _, s := range steps { - switch { - case s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix): - return "indeterminate (an external lookup failed; the item may resolve on a later attempt)" - // A stage error or an unreadable candidate means a source was found but not processed; the - // worker retries rather than settling absent, so neither reads as a clean miss. - case s.Outcome == artwork.OutcomeError, s.Outcome == artwork.OutcomeUnreadable: - return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)" - } - } - return "not resolved" -} - -// explainConfig names the setting that decides where a kind's artwork comes from, and its value. -func explainConfig(kind model.Kind) (name, value string) { - switch kind { - case model.KindArtistArtwork: - return "ArtistArtPriority", conf.Server.ArtistArtPriority - case model.KindAlbumArtwork: - return "CoverArtPriority", conf.Server.CoverArtPriority - case model.KindDiscArtwork: - return "DiscArtPriority", conf.Server.DiscArtPriority - case model.KindMediaFileArtwork: - return "EnableMediaFileCoverArt", strconv.FormatBool(conf.Server.EnableMediaFileCoverArt) - } - return "", "" -} - -type explainReport struct { - kind model.Kind - id string - name string - stored *model.ItemArtwork - queued *model.ArtworkQueueItem - agents string - // steps is the chain walk: recorded when the item was resolved, or performed just now when walked. - steps []artwork.TraceStep - source string - walked bool - resolveErr error -} - -// explainChainOrigin says whether the operator is reading history or a walk performed just now, -// since the two can disagree after a config change. -func explainChainOrigin(rep explainReport) string { - if rep.walked { - return "walked now" - } - if rep.stored != nil { - return "recorded " + formatTime(rep.stored.AttemptedAt) - } - return "not recorded" -} - -// writeSteps prints the trace rows. An empty last cell would end tabwriter's column block and -// break the alignment, so a missing detail is rendered as a dash. -func writeSteps(w io.Writer, indent string, steps []artwork.TraceStep) { - for _, s := range steps { - fmt.Fprintf(w, "%s%s\t%s\t%s\n", indent, s.Candidate, s.Outcome, cmp.Or(s.Detail, "-")) - } -} - -// writeStepTable prints a secondary trace, and nothing at all when there is none to show. -func writeStepTable(w io.Writer, title string, steps []artwork.TraceStep) { - if len(steps) == 0 { - return - } - // No tab on the title: it closes the preceding column block, so these rows align among themselves. - fmt.Fprintf(w, " %s:\n", title) - writeSteps(w, " ", steps) -} - -func formatExplain(rep explainReport) string { - var sb strings.Builder - w := newTabWriter(&sb) - explainable := artwork.Explainable(rep.kind) - stateful := artwork.KeepsState(rep.kind) - unrecorded := !rep.walked && rep.stored == nil - - fmt.Fprintln(w, "Item") - fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix()) - fmt.Fprintf(w, " ID:\t%s\n", rep.id) - fmt.Fprintf(w, " Name:\t%s\n", rep.name) - - fmt.Fprintln(w, "\nStored") - switch { - case !stateful: - fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.kind) - case rep.stored == nil: - fmt.Fprintln(w, " (no artwork state recorded)") - default: - fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.stored.Source)) - fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.stored.Hash, "(absent)")) - if rep.stored.SourcePath != "" { - fmt.Fprintf(w, " Source path:\t%s\n", rep.stored.SourcePath) - } - fmt.Fprintf(w, " Attempted at:\t%s\n", formatTime(rep.stored.AttemptedAt)) - } - - fmt.Fprintln(w, "\nQueue") - switch { - case !stateful: - fmt.Fprintln(w, " (never queued)") - case rep.queued == nil: - fmt.Fprintln(w, " (not queued)") - default: - fmt.Fprintf(w, " Priority:\t%s (%d)\n", priorityName(rep.queued.Priority), rep.queued.Priority) - fmt.Fprintf(w, " Attempts:\t%d\n", rep.queued.Attempts) - fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt)) - } - if rep.queued != nil { - writeStepTable(w, "Last attempt failed", artwork.DecodeTrace(rep.queued.Trace, "")) - } - if rep.stored != nil { - writeStepTable(w, "Gave up after", artwork.DecodeTrace(rep.stored.LastFailure, "")) - } - - fmt.Fprintln(w, "\nConfig") - if setting, value := explainConfig(rep.kind); setting == "" { - fmt.Fprintln(w, " (no artwork source configuration applies)") - } else { - fmt.Fprintf(w, " %s:\t%s\n", setting, value) - if rep.agents != "" { - fmt.Fprintf(w, " Agents:\t%s\n", rep.agents) - } - } - - fmt.Fprintf(w, "\nChain (%s)\n", explainChainOrigin(rep)) - switch { - case !explainable: - fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind) - case unrecorded: - fmt.Fprintln(w, " (no resolution recorded yet; re-run with --live to walk the chain now)") - case !rep.walked && len(rep.steps) == 0 && rep.stored.Hash != "": - // A stored image with no chain can only predate trace recording: a recorded resolution that - // found an image always records its winning candidate. - fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)") - case !rep.walked && len(rep.steps) == 0: - // Absent with no chain: an empty priority list walked nothing, or a pre-tracing absent row. - fmt.Fprintln(w, " (no candidates were recorded; re-run with --live to walk the chain now)") - default: - fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL") - writeSteps(w, " ", rep.steps) - } - - fmt.Fprintln(w, "\nResult") - switch { - case rep.resolveErr != nil: - fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr) - case !explainable: - fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)") - case unrecorded: - fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)") - default: - fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps)) - } - - w.Flush() - return sb.String() -} - -func formatTime(t time.Time) string { - if t.IsZero() { - return "-" - } - return t.Format(time.RFC3339) -} - -func runExplain(ctx context.Context, args []string) { - defer db.Init(ctx)() - ds, ctx := getAdminContext(ctx) - - targets, failures, err := resolveArtworkTargets(ctx, ds, args, explainKinds) - if err != nil { - log.Fatal(ctx, err) - } - if len(failures) > 0 { - log.Fatal(ctx, failures[0]) - } - if len(targets) != 1 { - log.Fatal(ctx, "explain takes a single item; pass one id or a pair") - } - kind, id := targets[0].Kind, targets[0].ID - - name, err := artwork.ItemName(ctx, ds, kind, id) - if err != nil { - log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err) - } - rep := explainReport{kind: kind, id: id, name: name} - if artwork.KeepsState(kind) { - rep.stored, err = ds.Artwork().GetItemArtwork(ctx, kind, id, model.ImageTypePrimary) - if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err) - } - rep.queued, err = ds.ArtworkQueue().Get(ctx, kind, id, model.ImageTypePrimary) - if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err) - } - } - - // Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now. - rep.walked = explainLive || !artwork.KeepsState(kind) - if artwork.Explainable(kind) { - // Only artist and album reach an agent, and the load must precede the resolver, which reads - // the same manager. - if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork { - mgr := loadPluginAgents(ctx, explainLive) - defer func() { _ = mgr.Stop() }() - rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind)) - } - switch { - case rep.walked: - trace := &artwork.ChainTrace{} - rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id) - rep.steps = trace.Steps() - case rep.stored != nil: - rep.steps = artwork.DecodeTrace(rep.stored.Trace, rep.stored.SourcePath) - rep.source = rep.stored.Source - } - } - fmt.Print(formatExplain(rep)) - // The steps taken before a failed walk are the diagnosis, so report them before exiting. - if rep.resolveErr != nil { - log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.resolveErr) - } -} diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go deleted file mode 100644 index df0ea0665..000000000 --- a/cmd/artwork_test.go +++ /dev/null @@ -1,1206 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "io" - "strings" - "time" - - "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/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("parseArtworkKind", func() { - It("accepts a supported kind", func() { - k, err := parseArtworkKind("ar", artwork.ReprocessKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(k).To(Equal(model.KindArtistArtwork)) - }) - - It("rejects an unknown kind and lists the valid ones", func() { - _, err := parseArtworkKind("zz", artwork.ReprocessKinds) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("ar")) - Expect(err.Error()).To(ContainSubstring("al")) - }) - - It("rejects a known kind the command does not accept", func() { - _, err := parseArtworkKind("mf", artwork.ReprocessKinds) - Expect(err).To(HaveOccurred()) - }) - - DescribeTable("accepts the kinds each command supports", - func(prefix string, valid []model.Kind) { - _, err := parseArtworkKind(prefix, valid) - Expect(err).ToNot(HaveOccurred()) - }, - Entry("explain reads disc artwork", "dc", explainKinds), - Entry("explain reads media file artwork", "mf", explainKinds), - // Disc artwork has no state to clear and the worker cannot resolve it, so refresh must not - // accept it: the queue row would be rejected on every drain. - Entry("refresh re-queues media files", "mf", artwork.RefreshableKinds), - ) - - It("rejects disc artwork for refresh", func() { - _, err := parseArtworkKind("dc", artwork.RefreshableKinds) - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("resolveArtworkTargets", func() { - var ds *tests.MockDataStore - ctx := context.Background() - - BeforeEach(func() { - artists := tests.CreateMockArtistRepo() - artists.SetData(model.Artists{{ID: "artist1"}}) - ds = &tests.MockDataStore{MockedArtist: artists} - }) - - It("accepts the explicit leader shared by every id", func() { - targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"al", "x", "y"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(failures).To(BeEmpty()) - Expect(targets).To(Equal([]model.ArtworkID{ - {Kind: model.KindAlbumArtwork, ID: "x"}, {Kind: model.KindAlbumArtwork, ID: "y"}})) - }) - - It("rejects an explicit kind the command does not accept as a usage error", func() { - _, _, err := resolveArtworkTargets(ctx, ds, []string{"dc", "x"}, artwork.RefreshableKinds) - Expect(err).To(MatchError(ContainSubstring("invalid kind"))) - }) - - It("resolves a bare id by looking it up across tables", func() { - targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(failures).To(BeEmpty()) - Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindArtistArtwork, ID: "artist1"}})) - }) - - It("reads the kind from a full artwork id prefix without a database lookup", func() { - targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}})) - }) - - It("strips the hash suffix from a full artwork id", func() { - targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum_0123456789abcdef"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}})) - }) - - It("collects a self-describing arg whose kind the command does not accept", func() { - targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"dc-realalbum:2"}, artwork.RefreshableKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(BeEmpty()) - Expect(failures).To(HaveLen(1)) - Expect(failures[0]).To(MatchError(ContainSubstring("invalid kind"))) - }) - - It("collects an id that matches nothing and has no kind prefix", func() { - targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"nope"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(BeEmpty()) - Expect(failures).To(HaveLen(1)) - Expect(failures[0]).To(MatchError(ContainSubstring("could not determine kind"))) - }) - - It("resolves the valid ids and collects the unresolvable ones", func() { - targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1", "nope", "al-realalbum"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(Equal([]model.ArtworkID{ - {Kind: model.KindArtistArtwork, ID: "artist1"}, {Kind: model.KindAlbumArtwork, ID: "realalbum"}})) - Expect(failures).To(HaveLen(1)) - Expect(failures[0]).To(MatchError(ContainSubstring("could not determine kind"))) - }) -}) - -var _ = Describe("explainResult", func() { - It("reports the winning source", func() { - steps := []artwork.TraceStep{{Candidate: "folder", Outcome: "hit", Detail: "/music/a.jpg"}} - Expect(explainResult("folder", steps)).To(ContainSubstring("resolved from folder")) - }) - - It("reports not resolved when every candidate was tried and missed", func() { - steps := []artwork.TraceStep{ - {Candidate: "artist.*", Outcome: "miss"}, - {Candidate: "external:deezer", Outcome: "miss"}, - } - Expect(explainResult("", steps)).To(Equal("not resolved")) - }) - - It("reports indeterminate when a local candidate exists but could not be read", func() { - steps := []artwork.TraceStep{ - {Candidate: "cover.*", Outcome: "miss"}, - {Candidate: "embedded", Outcome: "unreadable"}, - } - Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), - "the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss") - }) - - It("reports indeterminate when a processing stage errored after a candidate was found", func() { - steps := []artwork.TraceStep{ - {Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"}, - {Candidate: "store", Outcome: "error", Detail: "disk full"}, - } - Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), - "a stage error is a processing failure the worker retries, not a definitive miss") - }) - - It("does not qualify a hit that an earlier unreadable candidate preceded", func() { - // chainState.try stamps only the external error onto a hit and drops the local one, so the - // worker settles this as found; warning about it would be a false alarm. - steps := []artwork.TraceStep{ - {Candidate: "embedded", Outcome: "unreadable"}, - {Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"}, - } - Expect(explainResult("folder", steps)).To(Equal("resolved from folder")) - }) - - It("reports indeterminate when an external lookup failed transiently", func() { - steps := []artwork.TraceStep{ - {Candidate: "artist.*", Outcome: "miss"}, - {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, - } - Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), - "a failed network call is not evidence that the item has no artwork") - }) - - It("qualifies a win a failed higher-priority external lookup could have taken", func() { - steps := []artwork.TraceStep{ - {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, - {Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}, - } - res := explainResult("artist.*", steps) - Expect(res).To(ContainSubstring("resolved from artist.*")) - Expect(res).To(ContainSubstring("indeterminate"), - "the resolver serves this hit but retries later, so the winner is provisional") - }) - - It("does not qualify an external win that followed a failed external lookup", func() { - steps := []artwork.TraceStep{ - {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, - {Candidate: "external:lastfm", Outcome: "hit", Detail: "http://img"}, - } - Expect(explainResult("external:lastfm", steps)).To(Equal("resolved from external:lastfm"), - "a later agent supplying the image discards the earlier error, so there is no retry to warn about") - }) - - It("does not qualify a win that outranked the failed external lookup", func() { - steps := []artwork.TraceStep{ - {Candidate: "artist.*", Outcome: "hit"}, - {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, - } - Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*")) - }) -}) - -var _ = Describe("explainAgents", func() { - It("accounts for every configured agent, marking the ones the CLI could not use", func() { - out := explainAgents("artist-nfo-metadata,apple-music,deezer,lastfm", []string{"deezer"}) - for _, name := range []string{"artist-nfo-metadata", "apple-music", "deezer", "lastfm"} { - Expect(out).To(ContainSubstring(name), - "a configured agent missing from this line reads as if it had never been configured") - } - Expect(out).To(ContainSubstring("not available to the CLI")) - }) - - It("does not mark anything when every configured agent is available", func() { - out := explainAgents("deezer, lastfm", []string{"lastfm", "deezer"}) - Expect(out).To(Equal("deezer, lastfm")) - }) - - It("reports an empty configuration as none, not as an unavailable agent", func() { - Expect(explainAgents("", nil)).To(Equal("(none)")) - }) -}) - -var _ = Describe("formatExplain", func() { - var rep explainReport - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.ArtistArtPriority = "external, artist.*" - rep = explainReport{ - kind: model.KindArtistArtwork, - id: "ar-1", - name: "Radiohead", - agents: "lastfm,spotify", - walked: true, - steps: []artwork.TraceStep{ - {Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"}, - {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, - }, - source: "", - } - }) - - It("reports the item, its config and the chain it walked", func() { - out := formatExplain(rep) - Expect(out).To(ContainSubstring("Radiohead")) - Expect(out).To(ContainSubstring("ar-1")) - Expect(out).To(ContainSubstring("ArtistArtPriority")) - Expect(out).To(ContainSubstring("lastfm,spotify")) - Expect(out).To(ContainSubstring("external:deezer")) - Expect(out).To(ContainSubstring("indeterminate")) - }) - - It("reports the absence of stored state and of a queue row", func() { - out := formatExplain(rep) - Expect(out).To(ContainSubstring("no artwork state recorded")) - Expect(out).To(ContainSubstring("not queued")) - }) - - It("prints the stored state and the queue row when they exist", func() { - attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) - rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc123", - SourcePath: "/music/cover.jpg", AttemptedAt: attempted} - rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2, - RetryAt: attempted.Add(time.Hour)} - rep.source = "folder" - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("abc123")) - Expect(out).To(ContainSubstring("/music/cover.jpg")) - Expect(out).To(ContainSubstring("2026-08-13T10:00:00Z")) - Expect(out).To(ContainSubstring("scan (50)"), "a bare 50 makes the operator look the priority up") - Expect(out).To(ContainSubstring("resolved from folder")) - }) - - It("marks a known-absent stored state instead of printing an empty hash", func() { - rep.stored = &model.ItemArtwork{AttemptedAt: time.Now()} - Expect(formatExplain(rep)).To(ContainSubstring("absent")) - }) - - It("reports a failed walk as failed, not as unresolved", func() { - rep.resolveErr = errors.New("no such directory") - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("resolution failed: no such directory")) - Expect(out).ToNot(ContainSubstring("indeterminate")) - Expect(out).To(ContainSubstring("external:deezer"), "the steps taken before the failure still print") - }) - - It("says a kind that does not walk a chain has no chain, without an empty table", func() { - conf.Server.CoverArtPriority = "cover.*, embedded" - rep.kind = model.KindPlaylistArtwork - rep.steps = nil - rep.agents = "" - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("does not walk a priority chain")) - Expect(out).ToNot(ContainSubstring("CANDIDATE"), - "an empty chain table reads as 'nothing was tried', which is false") - Expect(out).ToNot(ContainSubstring("not resolved"), - "nothing was resolved because nothing was attempted") - Expect(out).ToNot(ContainSubstring("CoverArtPriority"), - "the priority chain config does not govern this kind") - }) - - It("says disc artwork keeps no state instead of reporting it as unresolved state", func() { - conf.Server.DiscArtPriority = "cover.jpg, embedded" - rep = explainReport{ - kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)", - steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}}, - source: "folder", - walked: true, - } - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("never recorded")) - Expect(out).To(ContainSubstring("never queued")) - Expect(out).ToNot(ContainSubstring("no artwork state recorded"), - "a missing row would read as a lookup that failed, when disc artwork has no row by design") - Expect(out).To(ContainSubstring("DiscArtPriority")) - Expect(out).ToNot(ContainSubstring("Agents:"), "disc artwork never asks an agent") - Expect(out).To(ContainSubstring("resolved from folder")) - }) - - Context("stored traces", func() { - BeforeEach(func() { - rep.walked = false - rep.steps = nil - }) - - It("labels a recorded chain with when it was recorded, not as a walk done now", func() { - attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) - rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted} - rep.steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}} - rep.source = "folder" - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("Chain (recorded 2026-08-13T10:00:00Z)")) - Expect(out).To(ContainSubstring("/music/artist.jpg")) - Expect(out).To(ContainSubstring("resolved from folder")) - }) - - It("says so when the item has never been resolved", func() { - out := formatExplain(rep) - Expect(out).To(ContainSubstring("no resolution recorded yet")) - Expect(out).To(ContainSubstring("--live")) - Expect(out).ToNot(ContainSubstring("not resolved"), - "nothing was recorded, which is not the same as resolving to nothing") - }) - - It("distinguishes a row written before traces existed from one with an empty chain", func() { - rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now()} - - Expect(formatExplain(rep)).To(ContainSubstring("resolved before traces were recorded")) - }) - - It("does not call an absent row with an empty recorded chain a pre-tracing row", func() { - // An empty priority list records a real but empty chain and resolves absent; that is not a - // legacy row, so it must not be reported as resolved before tracing existed. - rep.stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()} - - out := formatExplain(rep) - Expect(out).ToNot(ContainSubstring("resolved before traces were recorded")) - Expect(out).To(ContainSubstring("no candidates were recorded")) - Expect(out).To(ContainSubstring("not resolved"), "the Result still reports the absence plainly") - }) - - It("prints why the last attempt failed and why it gave up", func() { - rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 3, - Trace: `[{"c":"decode","o":"error","d":"bad header"}]`} - rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now(), - LastFailure: `[{"c":"read","o":"error","d":"i/o timeout"}]`} - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("Last attempt failed")) - Expect(out).To(ContainSubstring("bad header")) - Expect(out).To(ContainSubstring("Gave up after")) - Expect(out).To(ContainSubstring("i/o timeout")) - }) - - It("omits the failure tables when there is no failure to report", func() { - out := formatExplain(rep) - Expect(out).ToNot(ContainSubstring("Last attempt failed")) - Expect(out).ToNot(ContainSubstring("Gave up after")) - }) - }) - - It("reports the setting that governs media file artwork", func() { - conf.Server.EnableMediaFileCoverArt = false - rep = explainReport{ - kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag", - walked: true, - steps: []artwork.TraceStep{ - {Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"}, - }, - } - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("EnableMediaFileCoverArt")) - Expect(out).To(ContainSubstring("false")) - Expect(out).To(ContainSubstring("not resolved")) - Expect(out).To(ContainSubstring("no artwork state recorded"), "media files do keep state") - }) -}) - -var _ = Describe("explainConfig", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DiscArtPriority = "cover.jpg" - conf.Server.EnableMediaFileCoverArt = true - }) - - DescribeTable("names the setting that decides where a kind's artwork comes from", - func(kind model.Kind, setting, value string) { - gotSetting, gotValue := explainConfig(kind) - Expect(gotSetting).To(Equal(setting)) - Expect(gotValue).To(Equal(value)) - }, - Entry("disc", model.KindDiscArtwork, "DiscArtPriority", "cover.jpg"), - Entry("media file", model.KindMediaFileArtwork, "EnableMediaFileCoverArt", "true"), - Entry("playlist has none", model.KindPlaylistArtwork, "", ""), - ) -}) - -var _ = Describe("artwork refresh command", func() { - It("requires at least one argument", func() { - Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{})).To(HaveOccurred()) - Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"id1"})).ToNot(HaveOccurred()) - Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1"})).ToNot(HaveOccurred()) - }) -}) - -var _ = Describe("artwork reprocess selection", func() { - It("errors when no selector is given", func() { - _, err := selectedKinds(nil, nil, false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("--all")) - }) - - It("returns every kind for --all", func() { - ks, err := selectedKinds(nil, nil, true) - Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(ConsistOf(artwork.ReprocessKinds)) - }) - - It("returns every kind for a source filter given without a kind", func() { - ks, err := selectedKinds(nil, []string{"folder"}, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(ConsistOf(artwork.ReprocessKinds), "--source alone is already a complete selection") - }) - - It("returns only the named kinds", func() { - ks, err := selectedKinds([]string{"ar"}, nil, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) - }) - - It("keeps a named kind selection alongside a source filter", func() { - ks, err := selectedKinds([]string{"ar"}, []string{"folder"}, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) - }) - - It("rejects an unknown kind", func() { - _, err := selectedKinds([]string{"zz"}, nil, false) - Expect(err).To(HaveOccurred()) - }) - - It("counts a repeated kind once", func() { - ks, err := selectedKinds([]string{"ar", "ar"}, nil, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) - }) -}) - -var _ = Describe("explain/reprocess source round trip", func() { - ctx := context.Background() - - // storedSource reads back the Source line explain printed, as an operator would copy it. - storedSource := func(out string) string { - GinkgoHelper() - for line := range strings.SplitSeq(out, "\n") { - if after, ok := strings.CutPrefix(strings.TrimSpace(line), "Source:"); ok { - return strings.TrimSpace(after) - } - } - Fail("explain printed no Source line") - return "" - } - - It("names the absent state as reprocess --source accepts it", func() { - ds := &tests.MockDataStore{} - art := ds.Artwork().(*tests.MockArtworkRepo) - Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(), - ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed()) - - shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1", - stored: &model.ItemArtwork{AttemptedAt: time.Now()}})) - - q := ds.ArtworkQueue() - Expect(validateSources(ctx, q, repositorySources([]string{shown}))).To(Succeed(), - "explain's spelling of a source must be pasteable into --source") - Expect(validateSources(ctx, q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(), - "a parenthesised name would be rejected, so explain must not print one") - }) -}) - -var _ = Describe("repositorySources", func() { - It("maps the user-facing absent name onto the stored empty source", func() { - Expect(repositorySources([]string{"absent", "folder"})).To(Equal([]string{"", "folder"})) - }) - - It("maps the failed name onto the pseudo-source, and back for display", func() { - Expect(repositorySources([]string{failedSource})).To(Equal([]string{model.ArtworkSourceFailed})) - Expect(displaySource(model.ArtworkSourceFailed)).To(Equal(failedSource)) - }) - - It("keeps an empty selection empty, meaning every source", func() { - Expect(repositorySources(nil)).To(BeEmpty()) - }) -}) - -var _ = Describe("promptConfirm", func() { - var out strings.Builder - - BeforeEach(func() { out.Reset() }) - - It("states the external cost and accepts an explicit yes", func() { - Expect(promptConfirm(strings.NewReader("y\n"), "re-resolve")(&out, 42, 7)).To(BeTrue()) - Expect(out.String()).To(ContainSubstring("re-resolve 42 items")) - Expect(out.String()).To(ContainSubstring("External lookups: ~7 estimated")) - }) - - It("defaults to no on anything else", func() { - Expect(promptConfirm(strings.NewReader("\n"), "re-resolve")(&out, 1, 1)).To(BeFalse()) - Expect(promptConfirm(strings.NewReader("nope\n"), "re-resolve")(&out, 1, 1)).To(BeFalse()) - Expect(promptConfirm(strings.NewReader(""), "re-resolve")(&out, 1, 1)).To(BeFalse()) - }) - - It("drops the external clause when no lookup will be made", func() { - Expect(promptConfirm(strings.NewReader("y\n"), "cancel")(&out, 3, 0)).To(BeTrue()) - Expect(out.String()).To(ContainSubstring("cancel 3 items.")) - Expect(out.String()).ToNot(ContainSubstring("External lookups")) - }) -}) - -var _ = Describe("confirmUnlessYes", func() { - var out strings.Builder - - BeforeEach(func() { out.Reset() }) - - It("prompts when --yes was not given", func() { - Expect(confirmUnlessYes(false, strings.NewReader("n\n"), "re-resolve")(&out, 5, 5)).To(BeFalse()) - Expect(out.String()).To(ContainSubstring("Continue?")) - }) - - It("bypasses the prompt only for --yes", func() { - Expect(confirmUnlessYes(true, strings.NewReader(""), "re-resolve")(&out, 5, 5)).To(BeTrue()) - Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads") - }) -}) - -var _ = Describe("reprocessArtwork", func() { - var ds *tests.MockDataStore - var art *tests.MockArtworkRepo - var queue *tests.MockArtworkQueueRepo - var out strings.Builder - var imageAgents artwork.ImageAgentCount - ctx := context.Background() - kinds := []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork} - accept := func(io.Writer, int64, int64) bool { return true } - decline := func(io.Writer, int64, int64) bool { return false } - - put := func(kind model.Kind, id, source string) { - Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, - ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed()) - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.CoverArtPriority = "cover.*, external" - conf.Server.ArtistArtPriority = "artist.*, external" - conf.Server.EnableM3UExternalAlbumArt = false - imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1} - ds = &tests.MockDataStore{} - art = ds.Artwork().(*tests.MockArtworkRepo) - queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) - out.Reset() - put(model.KindArtistArtwork, "ar-1", "external:deezer") - put(model.KindArtistArtwork, "ar-2", "") - put(model.KindAlbumArtwork, "al-1", "external:deezer") - put(model.KindAlbumArtwork, "al-2", "folder") - }) - - It("previews the per-kind breakdown and queues nothing on a dry run", func() { - Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, true, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("external:deezer")) - Expect(out.String()).To(ContainSubstring("artist")) - Expect(out.String()).To(ContainSubstring("album")) - Expect(out.String()).To(ContainSubstring("TOTAL")) - Expect(out.String()).To(ContainSubstring("Dry run")) - Expect(queue.Count(ctx)).To(BeZero()) - }) - - It("queues nothing when the operator declines", func() { - Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Aborted")) - Expect(queue.Count(ctx)).To(BeZero()) - }) - - DescribeTable("records the applied config only for a run that leaves nothing on the old one", - func(selected []model.Kind, sources []string, dryRun, applied bool) { - Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) - - Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed()) - - want := "stale-fingerprint" - if applied { - want = artwork.ConfigFingerprint() - } - Expect(ds.Property().Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(want)) - }, - Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true), - Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true), - Entry("filtered by source", artwork.ReprocessKinds, []string{"external:deezer"}, false, false), - Entry("a subset of kinds", []model.Kind{model.KindAlbumArtwork}, nil, false, false), - Entry("a dry run applies nothing", artwork.ReprocessKinds, nil, true, false), - ) - - It("queues the matching items at recheck priority, leaving their artwork state alone", func() { - Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) - - Expect(queue.Count(ctx)).To(Equal(int64(2))) - queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck)) - _, err = queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued") - - stored, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork") - }) - - It("targets the absent state", func() { - Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed()) - - Expect(queue.Count(ctx)).To(Equal(int64(1))) - _, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - }) - - It("reports matched and queued separately when part of the set is already queued", func() { - Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", - ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed()) - - Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items")) - Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1")) - queued, err := queue.Get(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump), - "an already-queued row keeps its priority and backoff") - }) - - It("stops at a selection that matches nothing instead of prompting", func() { - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, false, - func(io.Writer, int64, int64) bool { - Fail("must not prompt when there is nothing to queue") - return true - }, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Nothing")) - Expect(queue.Count(ctx)).To(BeZero()) - }) - - It("reports an empty selection as a dry run when one was asked for", func() { - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Nothing matches")) - Expect(out.String()).To(ContainSubstring("Dry run")) - }) - - It("shows the external estimate on a dry run, which never reaches the prompt", func() { - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("External lookups: ~2 estimated")) - }) - - It("bills every agent per item, not one lookup per item", func() { - imageAgents = artwork.ImageAgentCount{Artist: 2, Album: 3} - var external int64 - capture := func(_ io.Writer, _, e int64) bool { external = e; return false } - - Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, capture, &out)).To(Succeed()) - - Expect(external).To(Equal(int64(2*2+2*3)), "2 artists at 2 agents plus 2 albums at 3 agents") - Expect(out.String()).To(ContainSubstring("External lookups: ~10 estimated")) - }) - - It("names the estimate's blind spots instead of claiming a bound it cannot hold", func() { - Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, true, accept, &out)).To(Succeed()) - - // The count includes plugin agents once they load, so the caveat is about a failed load, - // not about plugins being invisible to the CLI. - Expect(out.String()).To(ContainSubstring("plugin agents counted only when they load")) - Expect(out.String()).To(ContainSubstring("local hits may need fewer")) - Expect(out.String()).ToNot(ContainSubstring("up to"), "plugin agents make any ceiling false") - Expect(out.String()).ToNot(ContainSubstring("at least"), "a local hit makes any floor false") - }) - - It("says so when the selection needs no external lookup", func() { - conf.Server.CoverArtPriority = "cover.*" - put(model.KindPlaylistArtwork, "pl-1", "playlist") - - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("External lookups: none")) - }) - - It("counts playlists as external cost when the m3u image fetch is enabled", func() { - conf.Server.CoverArtPriority = "cover.*" - conf.Server.EnableM3UExternalAlbumArt = true - put(model.KindPlaylistArtwork, "pl-1", "playlist") - var external int64 - capture := func(_ io.Writer, _, e int64) bool { external = e; return false } - - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed()) - - Expect(external).To(Equal(int64(1))) - Expect(out.String()).To(ContainSubstring("External lookups: ~1 estimated")) - }) - - It("bills a playlist for every album its grid samples, at every agent", func() { - imageAgents = artwork.ImageAgentCount{Album: 3} - put(model.KindPlaylistArtwork, "pl-1", "playlist") - var external int64 - capture := func(_ io.Writer, _, e int64) bool { external = e; return false } - - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed()) - - Expect(external).To(Equal(int64(artwork.PlaylistGridSamples*3)), - "one playlist samples 4 albums, each walking all 3 album agents") - }) - - It("counts only the kinds that call an external agent as external cost", func() { - put(model.KindRadioArtwork, "ra-1", "upload") - var total, external int64 - capture := func(_ io.Writer, t, e int64) bool { total, external = t, e; return false } - - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork, model.KindRadioArtwork}, - nil, imageAgents, false, capture, &out)).To(Succeed()) - - Expect(total).To(Equal(int64(3))) - Expect(external).To(Equal(int64(2)), "radio artwork never reaches an external agent") - }) - - It("rejects an unknown source and names the ones in use", func() { - err := reprocessArtwork(ctx, ds, kinds, []string{"externa:deezer"}, imageAgents, true, accept, &out) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("externa:deezer")) - Expect(err.Error()).To(ContainSubstring("external:deezer")) - Expect(err.Error()).To(ContainSubstring("folder")) - Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name") - Expect(queue.Count(ctx)).To(BeZero()) - }) - - It("accepts the absent filter with nothing absent, still rejecting a typo", func() { - put(model.KindArtistArtwork, "ar-2", "folder") - - Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{absentSource}), - imageAgents, false, accept, &out)).To(Succeed(), - "a reserved source must stay valid once the library has none of it") - Expect(out.String()).To(ContainSubstring("Nothing matches")) - Expect(queue.Count(ctx)).To(BeZero()) - - Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}), - imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected") - }) - - It("names failed among the valid sources when rejecting a typo", func() { - err := reprocessArtwork(ctx, ds, kinds, []string{"faild"}, imageAgents, true, accept, &out) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed"), - "failed is accepted but never stored, so it has to be named explicitly") - }) - - It("accepts a source another kind uses, letting the empty selection report itself", func() { - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, []string{"folder"}, - imageAgents, false, decline, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Nothing matches"), - "a well-formed filter must not be reported as a typo because of the kinds selected") - Expect(queue.Count(ctx)).To(BeZero()) - }) -}) - -var _ = Describe("artwork status command", func() { - It("takes no arguments", func() { - Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{})).ToNot(HaveOccurred()) - Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{"x"})).To(HaveOccurred()) - }) -}) - -var _ = Describe("collectStatus", func() { - var ds *tests.MockDataStore - var art *tests.MockArtworkRepo - var queue *tests.MockArtworkQueueRepo - ctx := context.Background() - - BeforeEach(func() { - ds = &tests.MockDataStore{} - art = ds.Artwork().(*tests.MockArtworkRepo) - queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) - put := func(kind model.Kind, id, source, hash string, attempted time.Time) { - Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, - ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed()) - } - put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now()) - put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour)) - // ar-3 is absent because it gave up, so the two absent artists split across the columns. - Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3", - ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed()) - put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now()) - Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", - ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed()) - }) - - It("reports the queue, the source distribution and the absent totals", func() { - rep, err := collectStatus(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - - Expect(rep.queue).To(ConsistOf(model.ArtworkQueueStat{ItemKind: "ar", - Priority: model.ArtworkPriorityBackfill, Count: 1})) - Expect(rep.sources).To(ContainElements( - sourceCount{kind: model.KindArtistArtwork, source: "external:deezer", count: 1}, - sourceCount{kind: model.KindArtistArtwork, source: "", count: 2}, - sourceCount{kind: model.KindAlbumArtwork, source: "folder", count: 1}, - )) - Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, noImage: 1, failed: 1}), - "two absent artists, one answered and one that gave up") - }) - - It("compares the stored fingerprint against the current one", func() { - Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed()) - - rep, err := collectStatus(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - Expect(rep.stored).To(Equal("old-fingerprint")) - Expect(rep.current).To(Equal(artwork.ConfigFingerprint()), - "the CLI must report the value backfill itself compares") - }) - - It("queues nothing", func() { - _, err := collectStatus(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - Expect(queue.Count(ctx)).To(Equal(int64(1)), "status must not enqueue anything") - }) -}) - -var _ = Describe("formatStatus", func() { - var rep statusReport - - BeforeEach(func() { - rep = statusReport{ - queue: []model.ArtworkQueueStat{ - {ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, - {ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, - }, - sources: []sourceCount{ - {kind: model.KindArtistArtwork, source: "external:deezer", count: 5}, - {kind: model.KindArtistArtwork, source: "", count: 2}, - }, - absent: []absentCount{ - {kind: model.KindArtistArtwork, noImage: 1, failed: 1}, - }, - inputs: []artwork.FingerprintInput{{Name: "Agents", Value: "deezer,lastfm"}}, - stored: "abc123", - current: "abc123", - } - }) - - // block isolates one section, so an assertion cannot be satisfied by a coincidence elsewhere. - block := func(out, header string) string { - GinkgoHelper() - _, after, found := strings.Cut(out, header+"\n") - Expect(found).To(BeTrue(), "the %q block must be printed", header) - body, _, _ := strings.Cut(after, "\n\n") - return body - } - - It("names the kind and the priority of every queued row", func() { - queue := block(formatStatus(rep), "Queue") - Expect(queue).To(MatchRegexp(`artist\s+backfill\s+2`)) - Expect(queue).To(MatchRegexp(`album\s+scan\s+1`)) - }) - - It("totals the queue", func() { - Expect(block(formatStatus(rep), "Queue")).To(MatchRegexp(`TOTAL\s+3`)) - }) - - It("counts each source, naming the empty one absent", func() { - sources := block(formatStatus(rep), "Sources") - Expect(sources).To(MatchRegexp(`artist\s+external:deezer\s+5`)) - Expect(sources).To(MatchRegexp(`artist\s+absent\s+2`)) - }) - - It("partitions the absent states into answered and gave up", func() { - out := block(formatStatus(rep), "Absent (resolved, no image found)") - Expect(out).To(ContainSubstring("NO IMAGE")) - Expect(out).To(MatchRegexp(`artist\s+1\s+1`), "1 answered plus 1 failed, summing to 2 absent") - Expect(formatStatus(rep)).To(ContainSubstring("artwork reprocess --source failed")) - }) - - It("says absent states are never retried on their own, and names both commands that do", func() { - out := formatStatus(rep) - Expect(out).To(ContainSubstring("nothing retries these")) - Expect(out).To(ContainSubstring("artwork reprocess --source absent")) - Expect(out).To(ContainSubstring("artwork reprocess --source failed")) - }) - - It("reports a matching fingerprint as up to date, whatever else is queued", func() { - Expect(block(formatStatus(rep), "Config")).To(MatchRegexp(`State:\s+up to date`)) - }) - - It("echoes the config inputs a fingerprint change would have come from", func() { - out := block(formatStatus(rep), "Config") - Expect(out).To(MatchRegexp(`Agents:\s+deezer,lastfm`)) - Expect(out).To(ContainSubstring("abc123"), "the fingerprint values themselves must be printed") - }) - - It("reports a changed fingerprint as stale artwork, and names the command that applies it", func() { - rep.stored = "older" - rep.queue = nil - - out := formatStatus(rep) - Expect(out).To(ContainSubstring("fingerprint changed")) - Expect(out).To(ContainSubstring("artwork reprocess --all")) - Expect(out).ToNot(ContainSubstring("up to date")) - }) - - It("reports a never-recorded fingerprint without printing an empty value", func() { - rep.stored = "" - - out := formatStatus(rep) - Expect(out).To(ContainSubstring("(none)")) - Expect(out).To(ContainSubstring("fingerprint changed")) - }) - - It("says the queue is empty instead of printing a headless table", func() { - rep.queue = nil - - out := formatStatus(rep) - Expect(out).To(ContainSubstring("empty")) - Expect(out).ToNot(ContainSubstring("PRIORITY")) - }) -}) - -var _ = Describe("refreshItems", func() { - var ds *tests.MockDataStore - var queue *tests.MockArtworkQueueRepo - var art *tests.MockArtworkRepo - var out strings.Builder - ctx := context.Background() - - BeforeEach(func() { - albums := tests.CreateMockAlbumRepo() - albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}}) - ds = &tests.MockDataStore{MockedAlbum: albums} - art = ds.Artwork().(*tests.MockArtworkRepo) - queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) - out.Reset() - }) - - It("clears the stored state and queues each id at Bump priority", func() { - Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(), - ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed()) - - Expect(refreshItems(ctx, ds, []model.ArtworkID{ - {Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero()) - - _, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump)) - Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n")) - }) - - It("skips an id that does not exist instead of queuing it", func() { - Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1)) - - _, err := queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row") - Expect(out.String()).To(BeEmpty()) - }) - - It("continues past a failing id and counts the failures", func() { - Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-1"}, - {Kind: model.KindAlbumArtwork, ID: "al-2"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(Equal(1)) - - Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"), - "the ids after a failure are still refreshed") - }) -}) - -var _ = Describe("needsImageAgents", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.CoverArtPriority = "cover.*, external" - conf.Server.ArtistArtPriority = "artist.*, external" - conf.Server.EnableM3UExternalAlbumArt = false - }) - - It("is false for a selection no agent can serve", func() { - Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork})).To(BeFalse()) - }) - - // The generated playlist grid resolves album art through the image agents, so a playlist - // selection needs the count even though no agent is asked for a playlist image directly. - It("is true for playlists, whose grid tiles resolve through the album chain", func() { - Expect(needsImageAgents([]model.Kind{model.KindPlaylistArtwork})).To(BeTrue()) - }) - - It("is true when any one of several kinds can reach an agent", func() { - Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork, model.KindAlbumArtwork})).To(BeTrue()) - }) - - It("is false once the chains no longer reach an agent", func() { - conf.Server.CoverArtPriority = "cover.*" - conf.Server.ArtistArtPriority = "artist.*" - Expect(needsImageAgents(artwork.ReprocessKinds)).To(BeFalse()) - }) -}) - -var _ = Describe("configuredAgents", func() { - BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) }) - - It("splits and trims the configured list", func() { - conf.Server.Agents = "lastfm, spotify ,deezer" - Expect(configuredAgents()).To(Equal([]string{"lastfm", "spotify", "deezer"})) - }) - - // An empty name would match no plugin, but it also must not make the list look non-empty: - // LoadPlugins treats an empty list as "load nothing". - It("drops empty entries rather than passing a name nothing can match", func() { - conf.Server.Agents = " , ," - Expect(configuredAgents()).To(BeEmpty()) - }) -}) - -var _ = Describe("parseArtworkPriority", func() { - It("accepts every name status prints", func() { - for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill, - model.ArtworkPriorityScan, model.ArtworkPriorityBump} { - Expect(parseArtworkPriority(priorityName(p))).To(Equal(p)) - } - }) - - It("rejects an unknown name and lists the valid ones", func() { - _, err := parseArtworkPriority("urgent") - Expect(err).To(MatchError(ContainSubstring(`invalid priority "urgent"`))) - Expect(err).To(MatchError(ContainSubstring("backfill"))) - }) - - // Accepting the raw numbers would make the help text a lie and let a typo like 11 select nothing. - It("rejects the numeric form", func() { - _, err := parseArtworkPriority("10") - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("artwork cancel selection", func() { - It("errors when no selector is given", func() { - _, _, err := cancelSelection(nil, nil, false) - Expect(err).To(MatchError(ContainSubstring("no selector given"))) - }) - - // Empty, not an enumeration of the known kinds: --all must also take a queue row whose kind - // this build does not recognise. - It("selects with no filter at all for --all", func() { - kinds, priorities, err := cancelSelection(nil, nil, true) - Expect(err).ToNot(HaveOccurred()) - Expect(kinds).To(BeEmpty()) - Expect(priorities).To(BeEmpty()) - }) - - // The queue holds media file rows, so --all must reach them. - It("accepts media file artwork, which reprocess does not", func() { - kinds, _, err := cancelSelection([]string{"mf"}, nil, false) - Expect(err).ToNot(HaveOccurred()) - Expect(kinds).To(Equal([]model.Kind{model.KindMediaFileArtwork})) - }) - - It("treats a priority filter on its own as a complete selection", func() { - kinds, priorities, err := cancelSelection(nil, []string{"backfill"}, false) - Expect(err).ToNot(HaveOccurred()) - Expect(kinds).To(BeEmpty(), "no kind filter means every kind") - Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill})) - }) - - It("returns only the named kinds and priorities", func() { - kinds, priorities, err := cancelSelection([]string{"ar", "al"}, []string{"backfill", "scan"}, false) - Expect(err).ToNot(HaveOccurred()) - Expect(kinds).To(Equal([]model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork})) - Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan})) - }) - - It("counts a repeated kind and a repeated priority once", func() { - kinds, priorities, err := cancelSelection([]string{"ar", "ar"}, []string{"bump", "bump"}, false) - Expect(err).ToNot(HaveOccurred()) - Expect(kinds).To(HaveLen(1)) - Expect(priorities).To(HaveLen(1)) - }) - - It("rejects an unknown kind", func() { - _, _, err := cancelSelection([]string{"zz"}, nil, false) - Expect(err).To(MatchError(ContainSubstring(`invalid kind "zz"`))) - }) - - It("rejects a kind that is never queued", func() { - _, _, err := cancelSelection([]string{"dc"}, nil, false) - Expect(err).To(MatchError(ContainSubstring("invalid kind"))) - }) - - It("rejects an unknown priority", func() { - _, _, err := cancelSelection(nil, []string{"urgent"}, false) - Expect(err).To(MatchError(ContainSubstring("invalid priority"))) - }) -}) - -var _ = Describe("cancelArtwork", func() { - var ds *tests.MockDataStore - var queue *tests.MockArtworkQueueRepo - var out strings.Builder - ctx := context.Background() - accept := func(io.Writer, int64, int64) bool { return true } - decline := func(io.Writer, int64, int64) bool { return false } - - BeforeEach(func() { - ds = &tests.MockDataStore{} - queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) - out.Reset() - Expect(queue.Enqueue(ctx, - model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBackfill}, - model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBump}, - model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-1", ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBackfill}, - )).To(Succeed()) - }) - - It("previews the per-kind breakdown and cancels nothing on a dry run", func() { - Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, nil, true, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("artist")) - Expect(out.String()).To(ContainSubstring("backfill")) - Expect(out.String()).To(ContainSubstring("TOTAL")) - Expect(out.String()).To(ContainSubstring("Dry run")) - Expect(queue.Count(ctx)).To(BeNumerically("==", 3)) - }) - - It("cancels nothing when the operator declines", func() { - Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Aborted")) - Expect(queue.Count(ctx)).To(BeNumerically("==", 3)) - }) - - It("deletes the selected rows and leaves the rest queued", func() { - Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed()) - - Expect(queue.Count(ctx)).To(BeNumerically("==", 1)) - _, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued") - Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items.")) - }) - - It("cancels every kind and priority when neither filter is given", func() { - Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed()) - Expect(queue.Count(ctx)).To(BeZero()) - }) - - It("stops at a selection that matches nothing instead of prompting", func() { - refuse := func(io.Writer, int64, int64) bool { - Fail("must not prompt when nothing matches") - return false - } - Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Nothing matches this selection.")) - Expect(queue.Count(ctx)).To(BeNumerically("==", 3)) - }) - - It("reports a queue read failure instead of reporting nothing to cancel", func() { - queue.Err = errors.New("read failed") - Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(MatchError(ContainSubstring("read failed"))) - }) -}) diff --git a/cmd/backup.go b/cmd/backup.go index 932a833f2..ab73f7537 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -2,7 +2,9 @@ package cmd import ( "context" - "path/filepath" + "fmt" + "os" + "strings" "time" "github.com/navidrome/navidrome/conf" @@ -29,7 +31,7 @@ func init() { pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero") backupRoot.AddCommand(pruneCmd) - restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)") + restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore") restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning") _ = restoreCommand.MarkFlagRequired("backup-file") backupRoot.AddCommand(restoreCommand) @@ -73,15 +75,27 @@ var ( func runBackup(ctx context.Context) { if backupDir != "" { - conf.Server.Backup.Path = conf.NewDir(backupDir) + conf.Server.Backup.Path = backupDir } - requireExistingDB() + idx := strings.LastIndex(conf.Server.DbPath, "?") + var path string + + if idx == -1 { + path = conf.Server.DbPath + } else { + path = conf.Server.DbPath[:idx] + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + log.Fatal("No existing database", "path", path) + return + } start := time.Now() path, err := db.Backup(ctx) if err != nil { - log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err) + log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err) } elapsed := time.Since(start) @@ -90,24 +104,43 @@ func runBackup(ctx context.Context) { func runPrune(ctx context.Context) { if backupDir != "" { - conf.Server.Backup.Path = conf.NewDir(backupDir) + conf.Server.Backup.Path = backupDir } if backupCount != -1 { conf.Server.Backup.Count = backupCount } - if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") { - log.Warn("Prune cancelled") - return + if conf.Server.Backup.Count == 0 && !force { + fmt.Println("Warning: pruning ALL backups") + fmt.Printf("Please enter YES (all caps) to continue: ") + var input string + _, err := fmt.Scanln(&input) + + if input != "YES" || err != nil { + log.Warn("Prune cancelled") + return + } } - requireExistingDB() + idx := strings.LastIndex(conf.Server.DbPath, "?") + var path string + + if idx == -1 { + path = conf.Server.DbPath + } else { + path = conf.Server.DbPath[:idx] + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + log.Fatal("No existing database", "path", path) + return + } start := time.Now() count, err := db.Prune(ctx) if err != nil { - log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err) + log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err) } elapsed := time.Since(start) @@ -116,29 +149,36 @@ func runPrune(ctx context.Context) { } func runRestore(ctx context.Context) { - requireExistingDB() + idx := strings.LastIndex(conf.Server.DbPath, "?") + var path string - // A relative --backup-file is resolved against Backup.Path, the same folder - // `backup create` writes to. Without this, the value was treated as relative - // to the working directory, where the file does not exist. - if !filepath.IsAbs(restorePath) { - backupPath, err := conf.Server.Backup.Path.Path() - if err != nil { - log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err) - return - } - restorePath = filepath.Join(backupPath, restorePath) + if idx == -1 { + path = conf.Server.DbPath + } else { + path = conf.Server.DbPath[:idx] } - if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") { - log.Warn("Restore cancelled") + if _, err := os.Stat(path); os.IsNotExist(err) { + log.Fatal("No existing database", "path", path) return } + if !force { + fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") + fmt.Printf("Please enter YES (all caps) to continue: ") + var input string + _, err := fmt.Scanln(&input) + + if input != "YES" || err != nil { + log.Warn("Restore cancelled") + return + } + } + start := time.Now() err := db.Restore(ctx, restorePath) if err != nil { - log.Fatal("Error restoring database", "backupFile", restorePath, err) + log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err) } elapsed := time.Since(start) diff --git a/cmd/doctor.go b/cmd/doctor.go deleted file mode 100644 index fd0eb1cd7..000000000 --- a/cmd/doctor.go +++ /dev/null @@ -1,99 +0,0 @@ -package cmd - -import ( - "context" - "database/sql" - "fmt" - "io" - "os" - - "github.com/navidrome/navidrome/db" - "github.com/spf13/cobra" -) - -func init() { - rootCmd.AddCommand(doctorCmd) -} - -var doctorCmd = &cobra.Command{ - Use: "doctor", - Short: "Check your Navidrome installation for problems", - Long: "Run read-only health checks and report what was found. Checks the database for " + - "corruption and foreign key violations, and reports whether 'navidrome search rebuild' " + - "can fix what it finds. This command never alters your data", - Run: func(cmd *cobra.Command, _ []string) { - runDoctor(cmd.Context()) - }, -} - -func runDoctor(ctx context.Context) { - requireExistingDB() - - healthy := doctor(ctx, db.Db(), os.Stdout) - db.Close(ctx) - if !healthy { - os.Exit(1) - } -} - -const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command." - -func printFindings(out io.Writer, check, noun string, items []string) { - fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun) - for _, item := range items { - fmt.Fprintln(out, " "+item) - } -} - -func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool { - healthy := true - - fmt.Fprintln(out, "Checking database integrity...") - issues, truncated, err := db.IntegrityCheck(ctx, database) - switch { - case err != nil: - fmt.Fprintln(out, "The integrity check could not complete: "+err.Error()) - fmt.Fprintln(out, recoveryAdvice) - return false - case len(issues) == 0: - fmt.Fprintln(out, "Integrity check passed.") - default: - healthy = false - printFindings(out, "Integrity check", "issue(s)", issues) - switch { - case truncated: - fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.") - fmt.Fprintln(out, recoveryAdvice) - case db.IsFTSCorruptionOnly(issues): - fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.") - default: - fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.") - fmt.Fprintln(out, recoveryAdvice) - } - } - - fmt.Fprintln(out, "Checking foreign keys...") - violations, err := db.ForeignKeyCheck(ctx, database) - switch { - case err != nil: - healthy = false - fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error()) - case len(violations) == 0: - fmt.Fprintln(out, "Foreign key check passed.") - default: - healthy = false - lines := make([]string, 0, len(violations)) - for _, v := range violations { - lines = append(lines, - fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent)) - } - printFindings(out, "Foreign key check", "violation(s)", lines) - fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+ - "in library data; the rest have to be removed by hand.") - } - - if healthy { - fmt.Fprintln(out, "Database is healthy.") - } - return healthy -} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go deleted file mode 100644 index 7f8cd9728..000000000 --- a/cmd/doctor_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package cmd - -import ( - "context" - "database/sql" - "os" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/db" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("doctor", func() { - var ( - ctx context.Context - dbPath string - database *sql.DB - out *strings.Builder - reopen func() - ) - - // A file-backed DB so specs can corrupt raw pages; a table named like a real FTS - // search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks. - BeforeEach(func() { - ctx = context.Background() - dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db") - reopen = func() { - var err error - database, err = sql.Open(db.Dialect, dbPath) - Expect(err).ToNot(HaveOccurred()) - database.SetMaxOpenConns(1) - } - reopen() - DeferCleanup(func() { _ = database.Close() }) - - for _, stmt := range []string{ - `create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`, - `insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`, - `create table library(id integer primary key)`, - `create table media_file(id integer primary key, library_id integer references library(id))`, - } { - _, err := database.ExecContext(ctx, stmt) - Expect(err).ToNot(HaveOccurred()) - } - out = &strings.Builder{} - }) - - It("reports a healthy database", func() { - Expect(doctor(ctx, database, out)).To(BeTrue()) - Expect(out.String()).To(ContainSubstring("Database is healthy.")) - }) - - It("points to 'search rebuild' when corruption is limited to the search index", func() { - _, err := database.ExecContext(ctx, - `update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`) - Expect(err).ToNot(HaveOccurred()) - - Expect(doctor(ctx, database, out)).To(BeFalse()) - Expect(out.String()).To(ContainSubstring("navidrome search rebuild")) - }) - - It("points to a backup restore when corruption is not limited to the search index", func() { - _, err := database.ExecContext(ctx, - `insert into library(id) - with recursive s(x) as (select 1 union all select x+1 from s where x < 200) - select x from s`) - Expect(err).ToNot(HaveOccurred()) - var rootPage, pageSize int64 - Expect(database.QueryRowContext(ctx, - `select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed()) - Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed()) - Expect(database.Close()).To(Succeed()) - f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600) - Expect(err).ToNot(HaveOccurred()) - _, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40) - Expect(err).ToNot(HaveOccurred()) - Expect(f.Close()).To(Succeed()) - reopen() - - Expect(doctor(ctx, database, out)).To(BeFalse()) - Expect(out.String()).To(ContainSubstring("backup restore")) - Expect(out.String()).ToNot(ContainSubstring("search rebuild")) - }) - - It("reports foreign key violations", func() { - _, err := database.ExecContext(ctx, `pragma foreign_keys = off`) - Expect(err).ToNot(HaveOccurred()) - _, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`) - Expect(err).ToNot(HaveOccurred()) - - Expect(doctor(ctx, database, out)).To(BeFalse()) - Expect(out.String()).To(ContainSubstring("Foreign key check reported")) - Expect(out.String()).To(ContainSubstring("media_file")) - Expect(out.String()).To(ContainSubstring("navidrome scan -f")) - // GC never touches player, share or playqueue, so don't promise a full cleanup. - Expect(out.String()).To(ContainSubstring("removed by hand")) - }) - - // Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the - // user to 'search rebuild', but the pragma stopped at its limit without saying so. - It("does not blame the search index when the issue list is truncated", func() { - for _, stmt := range []string{ - `create table t(a, b)`, - `with recursive s(x) as (select 1 union all select x+1 from s where x < 300) - insert into t select x, x + 10000 from s`, - `create index media_file_fts_probe on t(a)`, - `pragma writable_schema=on`, - `update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)' - where name = 'media_file_fts_probe'`, - } { - _, err := database.ExecContext(ctx, stmt) - Expect(err).ToNot(HaveOccurred()) - } - Expect(database.Close()).To(Succeed()) - reopen() - - Expect(doctor(ctx, database, out)).To(BeFalse()) - Expect(out.String()).ToNot(ContainSubstring("search rebuild")) - Expect(out.String()).To(ContainSubstring("backup restore")) - }) -}) diff --git a/cmd/inspect.go b/cmd/inspect.go index 05f569f3e..9f9270b1e 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -1,17 +1,13 @@ package cmd import ( - "context" "encoding/json" "fmt" - "path/filepath" "strings" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -32,21 +28,21 @@ var inspectCmd = &cobra.Command{ Long: "Show file tags as seen by Navidrome", Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - runInspector(cmd.Context(), args) + runInspector(args) }, } -var marshalers = map[string]func(any) ([]byte, error){ +var marshalers = map[string]func(interface{}) ([]byte, error){ "pretty": prettyMarshal, "toml": toml.Marshal, "yaml": yaml.Marshal, "json": json.Marshal, - "jsonindent": func(v any) ([]byte, error) { + "jsonindent": func(v interface{}) ([]byte, error) { return json.MarshalIndent(v, "", " ") }, } -func prettyMarshal(v any) ([]byte, error) { +func prettyMarshal(v interface{}) ([]byte, error) { out := v.([]core.InspectOutput) var res strings.Builder for i := range out { @@ -59,24 +55,18 @@ func prettyMarshal(v any) ([]byte, error) { return []byte(res.String()), nil } -func runInspector(ctx context.Context, args []string) { +func runInspector(args []string) { marshal := marshalers[format] if marshal == nil { log.Fatal("Invalid format", "format", format) } - libs := loadLibraries(ctx) - matcher := model.NewLibraryMatcher(libs) var out []core.InspectOutput for _, filePath := range args { if !model.IsAudioFile(filePath) { log.Warn("Not an audio file", "file", filePath) continue } - lib, ok := libraryForFile(matcher, filePath) - if !ok && len(libs) > 0 { - log.Warn("File is not in any library, using the global PID config", "file", filePath) - } - output, err := core.Inspect(filePath, lib, "") + output, err := core.Inspect(filePath, 1, "") if err != nil { log.Warn("Unable to process file", "file", filePath, "error", err) continue @@ -87,33 +77,3 @@ func runInspector(ctx context.Context, args []string) { data, _ := marshal(out) fmt.Println(string(data)) } - -// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB. -func loadLibraries(ctx context.Context) model.Libraries { - if dbFile, ok := existingDBFile(); !ok { - log.Warn(ctx, "No database found, using the global PID config", "path", dbFile) - return nil - } - defer db.Init(ctx)() - libs, err := persistence.New(db.Db()).Library().GetAll(ctx) - if err != nil { - log.Warn(ctx, "Could not load libraries, using the global PID config", err) - return nil - } - for i := range libs { - if absPath, err := filepath.Abs(libs[i].Path); err == nil { - libs[i].Path = absPath - } - } - return libs -} - -// libraryForFile falls back to the default library with no overrides, which uses the global PID config. -func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) { - if absPath, err := filepath.Abs(filePath); err == nil { - if lib, ok := matcher.FindLibrary(absPath); ok { - return lib, true - } - } - return model.Library{ID: model.DefaultLibraryID}, false -} diff --git a/cmd/inspect_test.go b/cmd/inspect_test.go deleted file mode 100644 index 728dc8770..000000000 --- a/cmd/inspect_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("inspect", func() { - Describe("libraryForFile", func() { - var matcher *model.LibraryMatcher - var root string - - BeforeEach(func() { - root = GinkgoT().TempDir() - cwd, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - matcher = model.NewLibraryMatcher(model.Libraries{ - {ID: 1, Path: filepath.Join(root, "music")}, - {ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"}, - }) - }) - - It("returns the library that contains an absolute path", func() { - lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3")) - Expect(ok).To(BeTrue()) - Expect(lib.ID).To(Equal(1)) - }) - - It("resolves a relative path against the working directory", func() { - lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3")) - Expect(ok).To(BeTrue()) - Expect(lib.PIDAlbum).To(Equal("folder")) - }) - - It("falls back to the default library without overrides", func() { - lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3")) - Expect(ok).To(BeFalse()) - Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID})) - }) - }) - - Describe("loadLibraries", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("does not create a database when there is none", func() { - dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db") - conf.Server.DbPath = dbFile + "?_journal_mode=WAL" - - Expect(loadLibraries(GinkgoT().Context())).To(BeNil()) - Expect(dbFile).ToNot(BeAnExistingFile()) - }) - }) -}) diff --git a/cmd/missing.go b/cmd/missing.go deleted file mode 100644 index ce95e39f0..000000000 --- a/cmd/missing.go +++ /dev/null @@ -1,171 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "encoding/csv" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strconv" - "strings" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/spf13/cobra" -) - -var missingListFormat string - -func init() { - missingListCmd.Flags().StringVarP(&missingListFormat, "format", "f", "csv", "output format [supported values: csv, json]") - missingCmd.AddCommand(missingListCmd) - missingCmd.AddCommand(missingFixCmd) - rootCmd.AddCommand(missingCmd) -} - -var ( - missingCmd = &cobra.Command{ - Use: "missing", - Short: "Manage missing files", - Long: "List files marked as missing and remap them onto existing files", - } - - missingListCmd = &cobra.Command{ - Use: "list", - Short: "List missing files", - Run: func(cmd *cobra.Command, _ []string) { - runMissingList(cmd.Context()) - }, - } - - missingFixCmd = &cobra.Command{ - Use: "fix ", - Short: "Remap a missing file onto an existing file", - Long: "Remap a file marked as missing onto an existing (non-missing) file, the same way\n" + - "the scanner reconciles moved or renamed files. Each argument may be a media file ID,\n" + - "a library-relative path, or a libraryID:path pair.", - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - runMissingFix(cmd.Context(), args[0], args[1]) - }, - } -) - -type displayMissingFile struct { - ID string `json:"id"` - LibraryID int `json:"libraryId"` - Title string `json:"title"` - Album string `json:"album"` - Artist string `json:"artist"` - Path string `json:"path"` -} - -func runMissingList(ctx context.Context) { - if missingListFormat != "csv" && missingListFormat != "json" { - log.Fatal("Invalid output format. Must be one of csv, json", "format", missingListFormat) - } - - ds, ctx := getAdminContext(ctx) - mfs, err := ds.MediaFile().GetCursor(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": true}, - Sort: "path", - }) - if err == nil { - err = writeMissingList(os.Stdout, missingListFormat, mfs) - } - if err != nil { - log.Fatal(ctx, "Failed to retrieve missing files", err) - } -} - -// writeMissingList streams the cursor so a library with many missing files doesn't get loaded into memory -func writeMissingList(w io.Writer, format string, mfs model.MediaFileCursor) error { - if format == "json" { - bw := bufio.NewWriter(w) - _, _ = io.WriteString(bw, "[") - sep := "" - for mf, err := range mfs { - if err != nil { - return err - } - j, _ := json.Marshal(displayMissingFile{ID: mf.ID, LibraryID: mf.LibraryID, Title: mf.Title, Album: mf.Album, Artist: mf.Artist, Path: mf.Path}) - _, _ = fmt.Fprintf(bw, "%s%s", sep, j) - sep = "," - } - _, _ = io.WriteString(bw, "]\n") - return bw.Flush() - } - - cw := csv.NewWriter(w) - _ = cw.Write([]string{"id", "library id", "title", "album", "artist", "path"}) - for mf, err := range mfs { - if err != nil { - return err - } - _ = cw.Write([]string{mf.ID, strconv.Itoa(mf.LibraryID), mf.Title, mf.Album, mf.Artist, mf.Path}) - } - cw.Flush() - return cw.Error() -} - -func runMissingFix(ctx context.Context, missingRef, targetRef string) { - ds, ctx := getAdminContext(ctx) - - missing := resolveMediaFile(ctx, ds, missingRef) - target := resolveMediaFile(ctx, ds, targetRef) - - if err := core.NewMaintenance(ds).RemapMissingFile(ctx, missing.ID, target.ID); err != nil { - log.Fatal(ctx, "Failed to remap missing file", "missing", missing.Path, "target", target.Path, err) - } - fmt.Printf("Remapped %q onto %q\n", missing.Path, target.Path) -} - -// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path). -func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile { - mf, err := ds.MediaFile().Get(ctx, ref) - if err == nil { - return mf - } - if !errors.Is(err, model.ErrNotFound) { - log.Fatal(ctx, "Error looking up media file", "ref", ref, err) - } - - mfs, err := ds.MediaFile().FindByPaths(ctx, []string{ref}) - if err != nil { - log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err) - } - if len(mfs) == 0 { - log.Fatal(ctx, "No media file found", "ref", ref) - } - mfs = preferQualified(ref, mfs) - if len(mfs) > 1 { - log.Fatal(ctx, "Path matches multiple files; disambiguate with an ID or libraryID:path", "ref", ref, "matches", len(mfs)) - } - return &mfs[0] -} - -// preferQualified resolves the ambiguity FindByPaths creates by searching a "libraryID:path" -// reference both ways: an explicit library wins over a file literally named like one. -func preferQualified(ref string, mfs model.MediaFiles) model.MediaFiles { - id, path, ok := strings.Cut(ref, ":") - if !ok { - return mfs - } - libraryID, err := strconv.Atoi(id) - if err != nil { - return mfs - } - qualified := slice.Filter(mfs, func(mf model.MediaFile) bool { - return mf.LibraryID == libraryID && strings.EqualFold(mf.Path, path) - }) - if len(qualified) == 0 { - return mfs - } - return qualified -} diff --git a/cmd/missing_test.go b/cmd/missing_test.go deleted file mode 100644 index 2e96cf3c7..000000000 --- a/cmd/missing_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package cmd - -import ( - "errors" - "strings" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("writeMissingList", func() { - cursor := func(err error, mfs ...model.MediaFile) model.MediaFileCursor { - return func(yield func(model.MediaFile, error) bool) { - for _, mf := range mfs { - if !yield(mf, nil) { - return - } - } - if err != nil { - yield(model.MediaFile{}, err) - } - } - } - song := model.MediaFile{ID: "1", LibraryID: 1, Path: "Bach: Goldberg/01.mp3", Title: "Aria", Album: "Goldberg", Artist: "Bach"} - - It("writes csv with a header, quoting as needed", func() { - var out strings.Builder - Expect(writeMissingList(&out, "csv", cursor(nil, song))).To(Succeed()) - Expect(out.String()).To(Equal("id,library id,title,album,artist,path\n1,1,Aria,Goldberg,Bach,Bach: Goldberg/01.mp3\n")) - }) - - It("writes a json array", func() { - var out strings.Builder - Expect(writeMissingList(&out, "json", cursor(nil, song, song))).To(Succeed()) - Expect(out.String()).To(MatchJSON(`[ - {"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"}, - {"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"} - ]`)) - }) - - It("writes an empty json array when nothing is missing", func() { - var out strings.Builder - Expect(writeMissingList(&out, "json", cursor(nil))).To(Succeed()) - Expect(out.String()).To(MatchJSON(`[]`)) - }) - - It("returns the cursor's error", func() { - var out strings.Builder - Expect(writeMissingList(&out, "csv", cursor(errors.New("boom"), song))).To(MatchError("boom")) - }) -}) - -var _ = Describe("preferQualified", func() { - target := model.MediaFile{ID: "want", LibraryID: 1, Path: "foo.mp3"} - decoy := model.MediaFile{ID: "decoy", LibraryID: 1, Path: "1:foo.mp3"} - - It("picks the library-qualified match over a literal path that looks like one", func() { - Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, decoy})).To(Equal(model.MediaFiles{target})) - }) - - It("picks the named library when the same path exists in two", func() { - other := model.MediaFile{ID: "other", LibraryID: 2, Path: "foo.mp3"} - Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, other})).To(Equal(model.MediaFiles{target})) - }) - - It("leaves an unqualified reference ambiguous", func() { - both := model.MediaFiles{target, {ID: "other", LibraryID: 2, Path: "foo.mp3"}} - Expect(preferQualified("foo.mp3", both)).To(Equal(both)) - }) - - It("leaves it alone when the prefix is not a library id", func() { - both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}} - Expect(preferQualified("x:foo.mp3", both)).To(Equal(both)) - }) - - It("leaves it alone when no candidate matches the qualified form", func() { - both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}} - Expect(preferQualified("9:nope.mp3", both)).To(Equal(both)) - }) -}) diff --git a/cmd/pls.go b/cmd/pls.go index bf16ea420..95cbe4eec 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -6,14 +6,13 @@ import ( "encoding/json" "errors" "fmt" - "io" "os" "path/filepath" "strconv" "strings" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -109,7 +108,7 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model. } options.Filters = squirrel.Eq{"owner_id": user.ID} } - pls, err := ds.Playlist().GetAll(ctx, options) + pls, err := ds.Playlist(ctx).GetAll(options) if err != nil { log.Fatal(ctx, "Failed to retrieve playlists", err) } @@ -117,17 +116,17 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model. } func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist { - playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false) + playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false) if err != nil && !errors.Is(err, model.ErrNotFound) { log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if errors.Is(err, model.ErrNotFound) { - playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}}) + playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}}) if err != nil { log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if len(playlists) > 0 { - playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false) + playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false) if err != nil { log.Fatal("Error retrieving playlist", "name", nameOrID, err) } @@ -142,16 +141,14 @@ func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *mod func runExporter(ctx context.Context) { ds, ctx := getAdminContext(ctx) playlist := findPlaylist(ctx, ds, playlistID) - writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile) -} - -func writePlaylist(m3u string, out io.Writer, file string) { - if file == "" || file == "-" { - fmt.Fprint(out, m3u) + pls := playlist.ToM3U8() + if outputFile == "-" || outputFile == "" { + println(pls) return } - if err := os.WriteFile(file, []byte(m3u), 0600); err != nil { - log.Fatal("Error writing to the output file", "file", file, err) + err := os.WriteFile(outputFile, []byte(pls), 0600) + if err != nil { + log.Fatal("Error writing to the output file", "file", outputFile, err) } } @@ -160,7 +157,7 @@ func runExport(ctx context.Context) { if playlistID != "" && outputFile == "" { playlist := findPlaylist(ctx, ds, playlistID) - writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile) + println(playlist.ToM3U8()) return } @@ -194,7 +191,7 @@ func runExport(ctx context.Context) { exported := 0 for _, pls := range allPls { - plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false) + plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) if err != nil { log.Error("Error loading playlist tracks", "playlist", pls.Name, err) continue @@ -263,7 +260,7 @@ func runImport(ctx context.Context, files []string) { ctx = request.WithUser(ctx, *user) } - pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) for _, file := range files { absPath, err := filepath.Abs(file) diff --git a/cmd/pls_test.go b/cmd/pls_test.go deleted file mode 100644 index f3e8c7edd..000000000 --- a/cmd/pls_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("writePlaylist", func() { - const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n" - plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid())) - - BeforeEach(func() { - DeferCleanup(func() { _ = os.Remove(plsFile) }) - }) - - DescribeTable("writes the playlist to exactly one destination", - func(file, wantStream, wantFile string) { - var out strings.Builder - - writePlaylist(m3u, &out, file) - - written, _ := os.ReadFile(plsFile) - Expect(out.String()).To(Equal(wantStream)) - Expect(string(written)).To(Equal(wantFile)) - }, - Entry("no file name writes to the stream", "", m3u, ""), - Entry("a dash writes to the stream", "-", m3u, ""), - Entry("a path writes to the file", plsFile, "", m3u), - ) -}) diff --git a/cmd/plugin.go b/cmd/plugin.go deleted file mode 100644 index 7b8a9a393..000000000 --- a/cmd/plugin.go +++ /dev/null @@ -1,557 +0,0 @@ -package cmd - -import ( - "context" - "encoding/csv" - "encoding/json" - "fmt" - "io" - "os" - "strconv" - "strings" - "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().Get(ctx, 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().Get(ctx, 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 := newTabWriter(&sb) - 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().GetAll(ctx) - 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().Get(ctx, 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 deleted file mode 100644 index b6233057f..000000000 --- a/cmd/plugin_test.go +++ /dev/null @@ -1,360 +0,0 @@ -package cmd - -import ( - "context" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var samplePlugins = model.Plugins{ - {ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true}, - {ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"}, -} - -var _ = Describe("plugin command format flags", func() { - // Regression: list and info must not share a format variable. Binding the - // same var on both commands makes the last init() registration clobber the - // other's default, breaking `plugin list` with no -f flag. - It("defaults `list -f` to table", func() { - Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table")) - }) - - It("defaults `info -f` to text", func() { - Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text")) - }) -}) - -var _ = Describe("formatPluginList", func() { - It("renders csv with a header and one row per plugin", func() { - out, err := formatPluginList(samplePlugins, "csv") - Expect(err).ToNot(HaveOccurred()) - lines := strings.Split(strings.TrimSpace(out), "\n") - Expect(lines).To(HaveLen(3)) // header + 2 rows - Expect(lines[0]).To(ContainSubstring("id")) - Expect(out).To(ContainSubstring("alpha")) - Expect(out).To(ContainSubstring("beta")) - }) - - It("renders valid json", func() { - out, err := formatPluginList(samplePlugins, "json") - Expect(err).ToNot(HaveOccurred()) - var got []map[string]any - Expect(json.Unmarshal([]byte(out), &got)).To(Succeed()) - Expect(got).To(HaveLen(2)) - }) - - It("renders a human table by default", func() { - out, err := formatPluginList(samplePlugins, "table") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("Alpha")) - Expect(out).To(ContainSubstring("1.0.0")) - }) - - It("errors on an unknown format", func() { - _, err := formatPluginList(samplePlugins, "yaml") - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("enable/disable plugin", func() { - It("calls EnablePlugin on the manager", func() { - mgr := &tests.MockPluginManager{} - err := enablePlugin(context.Background(), mgr, "alpha") - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"})) - }) - - It("calls DisablePlugin on the manager", func() { - mgr := &tests.MockPluginManager{} - err := disablePlugin(context.Background(), mgr, "beta") - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.DisablePluginCalls).To(Equal([]string{"beta"})) - }) -}) - -var _ = Describe("applyPluginEdit", func() { - var cur *model.Plugin - BeforeEach(func() { - cur = &model.Plugin{ID: "alpha", Users: `["bob"]`, Libraries: `[1,2]`, AllowWriteAccess: true} - }) - - It("validates then updates config when config is provided", func() { - mgr := &tests.MockPluginManager{} - cfg := `{"key":"val"}` - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1)) - Expect(mgr.ValidatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg)) - Expect(mgr.UpdatePluginConfigCalls).To(HaveLen(1)) - Expect(mgr.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg)) - }) - - It("updates users with allUsers flag", func() { - mgr := &tests.MockPluginManager{} - all := true - err := applyPluginEdit(context.Background(), mgr, cur, - pluginEditOptions{allUsers: &all}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls).To(HaveLen(1)) - Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue()) - }) - - It("updates libraries with allLibraries and write access", func() { - mgr := &tests.MockPluginManager{} - all := true - wr := true - err := applyPluginEdit(context.Background(), mgr, cur, - pluginEditOptions{allLibraries: &all, writeAccess: &wr}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1)) - Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeTrue()) - Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeTrue()) - }) - - It("preserves existing fields when only the write-access flag changes", func() { - mgr := &tests.MockPluginManager{} - no := false - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{writeAccess: &no}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1)) - Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) // not wiped - Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeFalse()) - }) - - It("preserves existing users when only the all-users flag changes", func() { - mgr := &tests.MockPluginManager{} - all := true - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{allUsers: &all}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["bob"]`)) // not wiped - }) - - It("parses a comma-separated users value into a JSON array", func() { - mgr := &tests.MockPluginManager{} - users := "alice, bob" - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`)) - }) - - It("passes a JSON-array users value through unchanged", func() { - mgr := &tests.MockPluginManager{} - users := `["alice","bob"]` - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`)) - }) - - It("rejects a malformed JSON-array users value", func() { - mgr := &tests.MockPluginManager{} - users := `["alice"` - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) - Expect(err).To(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls).To(BeEmpty()) - }) - - It("parses a comma-separated libraries value into a JSON array of ints", func() { - mgr := &tests.MockPluginManager{} - libs := "1, 2" - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) - }) - - It("rejects a non-integer library ID", func() { - mgr := &tests.MockPluginManager{} - libs := "1,abc" - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) - Expect(err).To(HaveOccurred()) - Expect(mgr.UpdatePluginLibrariesCalls).To(BeEmpty()) - }) - - It("clears allUsers when an explicit users list is set", func() { - mgr := &tests.MockPluginManager{} - cur.AllUsers = true - users := "alice" - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice"]`)) - Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeFalse()) - }) - - It("clears allLibraries when an explicit libraries list is set", func() { - mgr := &tests.MockPluginManager{} - cur.AllLibraries = true - libs := "1" - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1]`)) - Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeFalse()) - }) - - It("does nothing and errors when no fields are set", func() { - mgr := &tests.MockPluginManager{} - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{}) - Expect(err).To(HaveOccurred()) - }) - - It("aborts the config update when validation fails", func() { - mgr := &tests.MockPluginManager{ValidateError: errors.New("bad config")} - cfg := `{"key":"val"}` - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg}) - Expect(err).To(HaveOccurred()) - Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1)) - Expect(mgr.UpdatePluginConfigCalls).To(BeEmpty()) - }) -}) - -var _ = Describe("isPackagePath", func() { - It("is true for any .ndp path", func() { - Expect(isPackagePath("/some/dir/x.ndp")).To(BeTrue()) - }) - It("is true for a non-existent .ndp path (so ReadManifest reports the error)", func() { - Expect(isPackagePath("/nope/x.ndp")).To(BeTrue()) - }) - It("is false for a bare plugin id", func() { - Expect(isPackagePath("my-plugin")).To(BeFalse()) - }) -}) - -var _ = Describe("formatPluginInfo", func() { - It("renders installed plugin details as text", func() { - p := &model.Plugin{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true} - out, err := formatPluginInfo(p, "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("alpha")) - Expect(out).To(ContainSubstring("Alpha")) - }) - It("renders json", func() { - p := &model.Plugin{ID: "alpha", Manifest: `{}`} - out, err := formatPluginInfo(p, "json") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("alpha")) - }) -}) - -var _ = Describe("formatManifestInfo", func() { - It("renders text with name, version, author", func() { - m := &plugins.Manifest{Name: "My Plugin", Version: "2.0.0", Author: "me"} - out, err := formatManifestInfo(m, "abc123", "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("My Plugin")) - Expect(out).To(ContainSubstring("2.0.0")) - Expect(out).To(ContainSubstring("me")) - }) - - It("omits Description and Website when nil", func() { - m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"} - out, err := formatManifestInfo(m, "abc123", "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).ToNot(ContainSubstring("Description:")) - Expect(out).ToNot(ContainSubstring("Website:")) - }) - - It("includes Description and Website when set", func() { - desc := "a cool plugin" - site := "https://example.com" - m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a", Description: &desc, Website: &site} - out, err := formatManifestInfo(m, "abc123", "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("a cool plugin")) - Expect(out).To(ContainSubstring("https://example.com")) - }) - - It("renders valid json", func() { - m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"} - out, err := formatManifestInfo(m, "abc123", "json") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("\"name\"")) - }) -}) - -var _ = Describe("formatPluginInfo enriched text", func() { - var fixedTime time.Time - - BeforeEach(func() { - fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) - }) - - It("includes Users, Libraries, Permissions, Created, Updated in text output", func() { - p := &model.Plugin{ - ID: "myplugin", - Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`, - Enabled: true, - Users: "alice,bob", - Libraries: "1,2", - CreatedAt: fixedTime, - UpdatedAt: fixedTime.Add(time.Hour), - } - out, err := formatPluginInfo(p, "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("alice,bob")) - Expect(out).To(ContainSubstring("1,2")) - Expect(out).To(ContainSubstring("subsonicapi")) - Expect(out).To(ContainSubstring("users")) - Expect(out).To(ContainSubstring("2025-01-15T12:00:00Z")) - }) - - It("omits Users and Libraries lines when empty", func() { - p := &model.Plugin{ - ID: "myplugin", - Manifest: `{"name":"X","version":"1.0.0","author":"me"}`, - CreatedAt: fixedTime, - UpdatedAt: fixedTime, - } - out, err := formatPluginInfo(p, "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).ToNot(ContainSubstring("Users:")) - Expect(out).ToNot(ContainSubstring("Libraries:")) - }) - - It("does not alter json output", func() { - p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"} - out, err := formatPluginInfo(p, "json") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring(`"x"`)) - }) -}) - -var _ = Describe("formatManifestInfo enriched text", func() { - It("includes Permissions when declared", func() { - p := &plugins.Permissions{Http: &plugins.HTTPPermission{}} - m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p} - out, err := formatManifestInfo(m, "abc123", "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("http")) - Expect(out).To(ContainSubstring("Permissions:")) - }) - - It("omits Permissions line when no permissions declared", func() { - m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"} - out, err := formatManifestInfo(m, "abc123", "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).ToNot(ContainSubstring("Permissions:")) - }) - - It("does not alter json output", func() { - p := &plugins.Permissions{Http: &plugins.HTTPPermission{}} - m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p} - out, err := formatManifestInfo(m, "abc123", "json") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring(`"name"`)) - }) -}) - -var _ = Describe("rescanPlugins", func() { - It("calls RescanPlugins on the manager", func() { - mgr := &tests.MockPluginManager{} - err := rescanPlugins(context.Background(), mgr) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.RescanPluginsCalls).To(Equal(1)) - }) -}) diff --git a/cmd/root.go b/cmd/root.go index e39c55365..08773176a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "net/http" "os" "os/signal" "strings" @@ -12,7 +11,6 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -44,9 +42,7 @@ Complete documentation is available at https://www.navidrome.org/docs`, preRun() }, Run: func(cmd *cobra.Command, args []string) { - if err := runNavidrome(cmd.Context()); err != nil { - log.Fatal("Fatal error in Navidrome. Aborting", err) - } + runNavidrome(cmd.Context()) }, PostRun: func(cmd *cobra.Command, args []string) { postRun() @@ -78,24 +74,20 @@ func postRun() { } // runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks. -// If any of the services returns an error, it stops the others and returns that error, so the caller can -// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil. -func runNavidrome(parentCtx context.Context) error { - defer db.Init(parentCtx)() +// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit, +// it will cancel the context and exit gracefully. +func runNavidrome(ctx context.Context) { + defer db.Init(ctx)() - g, ctx := errgroup.WithContext(parentCtx) + g, ctx := errgroup.WithContext(ctx) g.Go(startServer(ctx)) g.Go(startSignaller(ctx)) g.Go(startScheduler(ctx)) g.Go(startPlaybackServer(ctx)) - g.Go(startJellyfinDiscovery(ctx)) g.Go(schedulePeriodicBackup(ctx)) g.Go(startInsightsCollector(ctx)) - g.Go(scheduleDBAnalyzer(ctx)) + g.Go(scheduleDBOptimizer(ctx)) g.Go(startPluginManager(ctx)) - artworkWorker := CreateArtworkWorker() - g.Go(startArtworkWorker(ctx, artworkWorker)) - g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker)) g.Go(runInitialScan(ctx)) if conf.Server.Scanner.Enabled { g.Go(startScanWatcher(ctx)) @@ -104,11 +96,9 @@ func runNavidrome(parentCtx context.Context) error { log.Warn(ctx, "Automatic Scanning is DISABLED") } - // Errors caused by a normal shutdown are not failures - if err := g.Wait(); err != nil && parentCtx.Err() == nil { - return err + if err := g.Wait(); err != nil { + log.Error("Fatal error in Navidrome. Aborting", err) } - return nil } // mainContext returns a context that is cancelled when the process receives a signal to exit. @@ -134,12 +124,6 @@ func startServer(ctx context.Context) func() error { if conf.Server.ListenBrainz.Enabled { a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter()) } - if conf.Server.Jellyfin.Enabled { - a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx)) - } - if conf.Server.DevAPIv1 { - a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx)) - } if conf.Server.Prometheus.Enabled { p := CreatePrometheus() // blocking call because takes <100ms but useful if fails @@ -147,7 +131,7 @@ func startServer(ctx context.Context) func() error { a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler()) } if conf.Server.DevEnableProfiler { - a.MountRouter("Profiling", "/debug", profilerHandler()) + a.MountRouter("Profiling", "/debug", middleware.Profiler()) } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler()) @@ -156,14 +140,6 @@ func startServer(ctx context.Context) func() error { } } -// profilerHandler returns the pprof handler. net/http/pprof resolves the profile -// name from the raw request path, so the BasePath has to come off first. -func profilerHandler() http.Handler { - // A trailing or root slash would make StripPrefix drop the leading slash chi needs. - basePath := strings.TrimRight(conf.Server.BasePath, "/") - return http.StripPrefix(basePath, middleware.Profiler()) -} - // schedulePeriodicScan schedules a periodic scan of the music library, if configured. func schedulePeriodicScan(ctx context.Context) func() error { return func() error { @@ -190,50 +166,46 @@ func schedulePeriodicScan(ctx context.Context) func() error { } } -// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from -// the one used by their last finished scan -func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) { - libs, err := ds.Library().GetAll(ctx) +func pidHashChanged(ds model.DataStore) (bool, error) { + pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "") if err != nil { - return nil, err + return false, err } - var names []string - for _, lib := range libs { - if lib.PIDChanged() { - names = append(names, lib.Name) - } + pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "") + if err != nil { + return false, err } - return names, nil + return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil } // runInitialScan runs an initial scan of the music library if needed. func runInitialScan(ctx context.Context) func() error { return func() error { ds := CreateDataStore() - fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0") + fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0") if err != nil { return err } - inProgress, err := ds.Library().ScanInProgress(ctx) + inProgress, err := ds.Library(ctx).ScanInProgress() if err != nil { return err } - pidChangedLibs, err := librariesWithChangedPID(ctx, ds) + pidHasChanged, err := pidHashChanged(ds) if err != nil { return err } scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup - scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0 + scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan if scanNeeded { s := CreateScanner(ctx) switch { case fullScanRequired == "1": log.Warn(ctx, "Full scan required after migration") - _ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey) - case len(pidChangedLibs) > 0: - // Includes never-scanned libraries. The scanner rescans in full only the ones that need it - log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs) + _ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey) + case pidHasChanged: + log.Warn(ctx, "PID config changed, performing full scan") + fullScanRequired = "1" case inProgress: log.Warn(ctx, "Resuming interrupted scan") default: @@ -303,24 +275,16 @@ func schedulePeriodicBackup(ctx context.Context) func() error { } } -func scheduleDBAnalyzer(ctx context.Context) func() error { +func scheduleDBOptimizer(ctx context.Context) func() error { return func() error { - if !conf.Server.EnableScheduledDBAnalyze { - log.Info(ctx, "Scheduled DB analysis is DISABLED") - return nil - } - log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule) + log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule) schedulerInstance := scheduler.GetInstance() - _, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() { - release, ok := scanner.LockForMaintenance() - if !ok { - log.Debug(ctx, "Skipping DB analysis check because a scan is in progress") + _, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() { + if scanner.IsScanning() { + log.Debug(ctx, "Skipping DB optimization because a scan is in progress") return } - defer release() - if _, err := db.OptimizeIfNeeded(ctx); err != nil { - log.Error(ctx, "Error analyzing DB", err) - } + db.Optimize(ctx) }) return err } @@ -355,18 +319,6 @@ func startInsightsCollector(ctx context.Context) func() error { } } -// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server. -func startJellyfinDiscovery(ctx context.Context) func() error { - return func() error { - if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery { - log.Debug("Jellyfin auto-discovery is DISABLED") - return nil - } - CreateJellyfinDiscovery().Serve(ctx) - return nil - } -} - // startPlaybackServer starts the Navidrome playback server, if configured. // It is responsible for the Jukebox functionality func startPlaybackServer(ctx context.Context) func() error { @@ -381,60 +333,6 @@ func startPlaybackServer(ctx context.Context) func() error { } } -// startArtworkWorker starts the background artwork acquisition worker. It always -// runs; the queue is simply empty until something enqueues work into it. -func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error { - return func() error { - log.Info(ctx, "Starting artwork worker") - // The scanner writes to the DB for its whole run; competing for the write lock makes both fail. - worker.PauseWhile(scanner.IsScanning) - return worker.Run(ctx) - } -} - -// outsideScan runs a DB maintenance job unless a scan is running, and keeps a scan from starting -// until it ends; both write to the DB, and competing for the lock can make either fail. -func outsideScan(ctx context.Context, job string, run func(context.Context) error) { - release, ok := scanner.LockForMaintenance() - if !ok { - log.Debug(ctx, "Skipping "+job+" because a scan is in progress") - return - } - defer release() - if err := run(ctx); err != nil { - log.Error(ctx, "Error running "+job, err) - } -} - -// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and -// reports an artwork config change without acting on it. -func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error { - return func() error { - schedulerInstance := scheduler.GetInstance() - - if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() { - outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll) - }); err != nil { - log.Error(ctx, "Error scheduling artwork missing-state recheck", err) - } - - if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() { - outsideScan(ctx, "artwork prune", worker.RunPrune) - }); err != nil { - log.Error(ctx, "Error scheduling artwork prune", err) - } - - // Also run the missing-row recheck once at startup so a never-scanned entity is picked up - // immediately, not only on the next hourly tick (e.g. after enabling the feature). - outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll) - - if err := worker.ReconcileConfig(ctx); err != nil { - log.Error(ctx, "Error checking the artwork config fingerprint", err) - } - return nil - } -} - // startPluginManager starts the plugin manager, if configured. func startPluginManager(ctx context.Context) func() error { return func() error { @@ -485,7 +383,7 @@ func init() { rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized") rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`") - rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint") + rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`") rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics") _ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address")) diff --git a/cmd/root_test.go b/cmd/root_test.go deleted file mode 100644 index 423cd2a8f..000000000 --- a/cmd/root_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package cmd - -import ( - "errors" - "net/http" - "net/http/httptest" - "path" - "runtime/pprof" - - "github.com/go-chi/chi/v5" - "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 _ = pprof.NewProfile("nd-profiler-test") - -var _ = Describe("profilerHandler", func() { - // Mirrors how server.MountRouter mounts the handler. - mount := func() http.Handler { - router := chi.NewRouter() - router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler()) - return router - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - DescribeTable("serves a named profile", - func(basePath string) { - conf.Server.BasePath = basePath - - w := httptest.NewRecorder() - target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1" - mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0")) - }, - Entry("without a BasePath", ""), - Entry("with a BasePath", "/music"), - Entry("with a root BasePath", "/"), - Entry("with a trailing-slash BasePath", "/music/"), - ) -}) - -var _ = Describe("librariesWithChangedPID", func() { - var ds *tests.MockDataStore - var libs *tests.MockLibraryRepo - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - libs = &tests.MockLibraryRepo{} - ds = &tests.MockDataStore{MockedLibrary: libs} - }) - - It("returns only the libraries whose PID config changed", func() { - pid := model.Library{}.EffectivePID() - libs.SetData(model.Libraries{ - {ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, - {ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, - {ID: 3, Name: "Never scanned"}, - }) - Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned")) - }) - - It("returns the error from the repository", func() { - libs.Err = errors.New("db down") - _, err := librariesWithChangedPID(GinkgoT().Context(), ds) - Expect(err).To(MatchError("db down")) - }) -}) diff --git a/cmd/scan.go b/cmd/scan.go index 08eccbf2a..d8a563396 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -4,12 +4,11 @@ import ( "bufio" "context" "encoding/gob" - "errors" "fmt" "os" "strings" - "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -44,20 +43,15 @@ var scanCmd = &cobra.Command{ }, } -func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) { - var changesDetected bool - var scanErrors []error +func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) { for status := range pl.ReadOrDone(ctx, progress) { if status.Warning != "" { log.Warn(ctx, "Scan warning", "error", status.Warning) } if status.Error != "" { log.Error(ctx, "Scan error", "error", status.Error) - scanErrors = append(scanErrors, errors.New(status.Error)) - } - if status.ChangesDetected { - changesDetected = true } + // Discard the progress status, we only care about errors } if fullScan { @@ -65,7 +59,6 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre } else { log.Info("Finished rescan") } - return changesDetected, errors.Join(scanErrors...) } func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) { @@ -82,7 +75,7 @@ func runScanner(ctx context.Context) { sqlDB := db.Db() defer db.Db().Close() ds := persistence.New(sqlDB) - pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) // Parse targets from command line or file var scanTargets []model.ScanTarget @@ -102,16 +95,6 @@ func runScanner(ctx context.Context) { log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets)) } - effectiveFullScan := fullScan - if !subprocess { - effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets) - if effectiveFullScan { - if err := db.MarkOptimizePending(ctx); err != nil { - log.Error(ctx, "Error marking DB analysis pending", err) - } - } - } - progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets) if err != nil { log.Fatal(ctx, "Failed to scan", err) @@ -121,21 +104,7 @@ func runScanner(ctx context.Context) { if subprocess { trackScanAsSubprocess(ctx, progress) } else { - changesDetected, scanErr := trackScanInteractively(ctx, progress) - runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr) - } -} - -func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) { - if changesDetected { - if err := db.MarkOptimizePending(ctx); err != nil { - log.Error(ctx, "Error marking DB analysis pending", err) - } - } - if effectiveFullScan && scanErr == nil { - if err := db.Optimize(ctx); err != nil { - log.Error(ctx, "Error analyzing DB", err) - } + trackScanInteractively(ctx, progress) } } diff --git a/cmd/scan_test.go b/cmd/scan_test.go index 309d09f98..beeecca19 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -1,29 +1,14 @@ package cmd import ( - "context" "os" "path/filepath" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/scanner" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("trackScanInteractively", func() { - It("reports changes and scan errors", func() { - progress := make(chan *scanner.ProgressInfo, 2) - progress <- &scanner.ProgressInfo{ChangesDetected: true} - progress <- &scanner.ProgressInfo{Error: "scan failed"} - close(progress) - - changesDetected, err := trackScanInteractively(context.Background(), progress) - Expect(changesDetected).To(BeTrue()) - Expect(err).To(MatchError("scan failed")) - }) -}) - var _ = Describe("readTargetsFromFile", func() { var tempDir string diff --git a/cmd/search.go b/cmd/search.go deleted file mode 100644 index 46b7eebd5..000000000 --- a/cmd/search.go +++ /dev/null @@ -1,55 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/spf13/cobra" -) - -var searchRebuildForce bool - -func init() { - rootCmd.AddCommand(searchRoot) - - searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation") - searchRoot.AddCommand(searchRebuildCmd) -} - -var ( - searchRoot = &cobra.Command{ - Use: "search", - Short: "Search index maintenance", - } - - searchRebuildCmd = &cobra.Command{ - Use: "rebuild", - Short: "Rebuild the full-text search index", - Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " + - "or desynced search index without any data loss. Note that 'navidrome doctor' detects a " + - "corrupted index, but cannot tell when the index has merely drifted out of sync with the " + - "library. This must be done offline", - Run: func(cmd *cobra.Command, _ []string) { - runSearchRebuild(cmd.Context()) - }, - } -) - -func runSearchRebuild(ctx context.Context) { - requireExistingDB() - - if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") { - log.Warn("Rebuild cancelled") - return - } - - fmt.Println("Rebuilding the search index...") - err := db.RebuildFTS(ctx, db.Db()) - db.Close(ctx) - if err != nil { - log.Fatal("Error rebuilding the search index", err) - } - fmt.Println("Search index rebuilt successfully.") -} diff --git a/cmd/svc.go b/cmd/svc.go index 4e8b1fd85..89ca08056 100644 --- a/cmd/svc.go +++ b/cmd/svc.go @@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{ } type svcControl struct { - ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop + ctx context.Context cancel context.CancelFunc done chan struct{} } @@ -53,13 +53,8 @@ func (p *svcControl) Start(service.Service) error { p.done = make(chan struct{}) p.ctx, p.cancel = context.WithCancel(context.Background()) go func() { - err := runNavidrome(p.ctx) + runNavidrome(p.ctx) close(p.done) - // service.Run() only returns when it gets a stop request, so exit here to let the - // service manager see the failure and restart the service - if err != nil { - log.Fatal("Fatal error in Navidrome. Aborting", err) - } }() return nil } @@ -79,15 +74,15 @@ func (p *svcControl) Stop(service.Service) error { var svcInstance = sync.OnceValue(func() service.Service { options := make(service.KeyValue) options["Restart"] = "on-failure" - options["SuccessExitStatus"] = "SIGKILL" + options["SuccessExitStatus"] = "1 2 8 SIGKILL" options["UserService"] = false - options["LogDirectory"] = conf.Server.DataFolder.String() + options["LogDirectory"] = conf.Server.DataFolder options["SystemdScript"] = systemdScript if conf.Server.LogFile != "" { options["LogOutput"] = false } else { options["LogOutput"] = true - options["LogDirectory"] = conf.Server.DataFolder.String() + options["LogDirectory"] = conf.Server.DataFolder } svcConfig := &service.Config{ UserName: installUser, @@ -136,11 +131,11 @@ func buildInstallCmd() *cobra.Command { println("Installing service with:") println(" working directory: " + executablePath()) println(" music folder: " + conf.Server.MusicFolder) - println(" data folder: " + conf.Server.DataFolder.String()) + println(" data folder: " + conf.Server.DataFolder) if conf.Server.LogFile != "" { println(" log file: " + conf.Server.LogFile) } else { - println(" logs folder: " + conf.Server.DataFolder.String()) + println(" logs folder: " + conf.Server.DataFolder) } if cfgFile != "" { conf.Server.ConfigFile, err = filepath.Abs(cfgFile) @@ -237,21 +232,22 @@ func buildExecuteCmd() *cobra.Command { } const systemdScript = `[Unit] -Description={{Description}} -ConditionFileIsExecutable={{Path | cmdEscape}} -{{range Dependencies}}{{.}} -{{end}} +Description={{.Description}} +ConditionFileIsExecutable={{.Path|cmdEscape}} +{{range $i, $dep := .Dependencies}} +{{$dep}} {{end}} + [Service] StartLimitInterval=5 StartLimitBurst=10 -ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}} -{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}{{end}} -{{if UserName}}User={{UserName}}{{end}} -{{if Restart}}Restart={{Restart}}{{end}} -{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}{{end}} +ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}} +{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}} +{{if .UserName}}User={{.UserName}}{{end}} +{{if .Restart}}Restart={{.Restart}}{{end}} +{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}} TimeoutStopSec=20 RestartSec=120 -EnvironmentFile=-/etc/sysconfig/{{Name}} +EnvironmentFile=-/etc/sysconfig/{{.Name}} Environment="ND_SYSTEMD_PRIORITY_LOGGING=1" DevicePolicy=closed @@ -264,7 +260,7 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RestrictNamespaces=yes RestrictRealtime=yes SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap -{{if WorkingDirectory}}ReadWritePaths={{WorkingDirectory | cmdEscape}}{{end}} +{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}} ProtectSystem=full [Install] diff --git a/cmd/svc_test.go b/cmd/svc_test.go deleted file mode 100644 index 7c34563b3..000000000 --- a/cmd/svc_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package cmd - -import ( - "regexp" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("systemdScript template", func() { - systemdKeys := map[string]bool{ - "Description": true, "Path": true, "Name": true, "Dependencies": true, - "Arguments": true, "ChRoot": true, "WorkingDirectory": true, - "UserName": true, "ReloadSignal": true, "PIDFile": true, - "LogDirectory": true, "OutputFileSupport": true, "LimitNOFILE": true, - "Restart": true, "SuccessExitStatus": true, "EnvVars": true, - } - systemdFuncs := map[string]bool{"cmd": true, "cmdEscape": true} - - actionRe := regexp.MustCompile(`\{\{(.*?)\}\}`) - - parseAction := func(action string) (key string, funcs []string) { - action = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(action, "-"), "-")) - kw, rest, _ := strings.Cut(action, " ") - switch kw { - case "end", "else": - return "", nil - case "if", "range": - return strings.TrimSpace(rest), nil - } - parts := strings.Split(action, "|") - for _, p := range parts[1:] { - funcs = append(funcs, strings.TrimSpace(p)) - } - return strings.TrimSpace(parts[0]), funcs - } - - It("only references keys and functions the service library provides", func() { - matches := actionRe.FindAllStringSubmatch(systemdScript, -1) - Expect(matches).ToNot(BeEmpty()) - - for _, m := range matches { - key, funcs := parseAction(m[1]) - if key != "" && key != "." { - Expect(systemdKeys).To(HaveKey(key), - "template action %q uses a key unknown to kardianos/service", m[0]) - } - for _, fn := range funcs { - Expect(systemdFuncs).To(HaveKey(fn), - "template action %q uses an unknown pipeline function", m[0]) - } - } - }) -}) diff --git a/cmd/user.go b/cmd/user.go index eb64e69fe..1abf157b7 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) { ds, ctx := getAdminContext(ctx) err := ds.WithTx(func(tx model.DataStore) error { - existingUser, err := tx.User().FindByUsername(ctx, userID) + existingUser, err := tx.User(ctx).FindByUsername(userID) if existingUser != nil { return fmt.Errorf("existing user '%s'", userID) } @@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) { } if len(libraryIds) > 0 && !setAdmin { - user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) + user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) if err != nil { return err } @@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) { return libraryError(user.Libraries) } } else { - user.Libraries, err = tx.Library().GetAll(ctx) + user.Libraries, err = tx.Library(ctx).GetAll() if err != nil { return err } } - err = tx.User().Put(ctx, &user) + err = tx.User(ctx).Put(&user) if err != nil { return err } @@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) { updatedIds[idx] = lib.ID } - err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds) + err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds) return err }) @@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) { var user *model.User err = ds.WithTx(func(tx model.DataStore) error { - count, err := tx.User().CountAll(ctx) + count, err := tx.User(ctx).CountAll() if err != nil { return err } @@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) { return err } - return tx.User().Delete(ctx, user.ID) + return tx.User(ctx).Delete(user.ID) }) if err != nil { @@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) { } if len(libraryIds) > 0 && !setAdmin { - libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) + libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) if err != nil { return err @@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) { } if setAdmin && !user.IsAdmin { - libraries, err := tx.Library().GetAll(ctx) + libraries, err := tx.Library(ctx).GetAll() if err != nil { return err } @@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) { return nil } - err := tx.User().Put(ctx, user) + err := tx.User(ctx).Put(user) if err != nil { return err } @@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) { updatedIds[idx] = lib.ID } - err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds) + err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds) if err != nil { return err } @@ -393,11 +393,13 @@ func runUserList(ctx context.Context) { ds, ctx := getAdminContext(ctx) - userList, err := ds.User().ReadAll(ctx) + users, err := ds.User(ctx).ReadAll() if err != nil { log.Fatal(ctx, "Failed to retrieve users", err) } + userList := users.(model.Users) + if outputFormat == "csv" { w := csv.NewWriter(os.Stdout) _ = w.Write([]string{ diff --git a/cmd/utils.go b/cmd/utils.go index f35a31fb1..81d646cf1 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -4,12 +4,7 @@ import ( "context" "errors" "fmt" - "io" - "os" - "strings" - "text/tabwriter" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -18,33 +13,6 @@ import ( "github.com/navidrome/navidrome/persistence" ) -// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists. -func existingDBFile() (string, bool) { - path, _, _ := strings.Cut(conf.Server.DbPath, "?") - _, err := os.Stat(path) - return path, err == nil -} - -// requireExistingDB aborts the command when the database file does not exist. -func requireExistingDB() { - if path, ok := existingDBFile(); !ok { - log.Fatal("No existing database", "path", path) - } -} - -func confirmYES(warning string) bool { - fmt.Println(warning) - fmt.Printf("Please enter YES (all caps) to continue: ") - var input string - _, err := fmt.Scanln(&input) - return input == "YES" && err == nil -} - -// newTabWriter keeps every CLI table on the same column settings. -func newTabWriter(out io.Writer) *tabwriter.Writer { - return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) -} - func getAdminContext(ctx context.Context) (model.DataStore, context.Context) { sqlDB := db.Db() ds := persistence.New(sqlDB) @@ -57,14 +25,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) { } func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) { - user, err := ds.User().FindByUsername(ctx, id) + user, err := ds.User(ctx).FindByUsername(id) if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, fmt.Errorf("finding user by name: %w", err) } if errors.Is(err, model.ErrNotFound) { - user, err = ds.User().Get(ctx, id) + user, err = ds.User(ctx).Get(id) if err != nil { return nil, fmt.Errorf("finding user by id: %w", err) } diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 2a396689d..0939eef4d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -21,7 +21,6 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" @@ -31,9 +30,7 @@ import ( "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/server/apiv1" "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" @@ -67,22 +64,25 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - uploader := artwork.NewUploader(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) insights := metrics.GetInstance(dataStore) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) - watcher := scanner.GetWatcher(dataStore, modelScanner) manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + watcher := scanner.GetWatcher(dataStore, modelScanner) library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) - quickConnect := quickconnect.GetInstance() - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService) return router } @@ -90,79 +90,49 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() - imageStore := artwork.GetImageStore() fFmpeg := ffmpeg.New() - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) - transcodingCache := stream.GetTranscodingCache() - mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) - transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) - share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) - players := core.NewPlayers(dataStore) broker := events.GetBroker() metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) - uploader := artwork.NewUploader(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) - modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + share := core.NewShare(dataStore) + archiver := core.NewArchiver(mediaStreamer, dataStore, share) + players := core.NewPlayers(dataStore) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) - lyricsLyrics := lyrics.NewLyrics(dataStore, manager) + lyricsLyrics := lyrics.NewLyrics(manager) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) return router } -func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - fileCache := artwork.GetImageCache() - imageStore := artwork.GetImageStore() - fFmpeg := ffmpeg.New() - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) - transcodingCache := stream.GetTranscodingCache() - mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) - transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) - players := core.NewPlayers(dataStore) - broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) - uploader := artwork.NewUploader(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) - sonicSonic := sonic.New(dataStore, manager, matcherMatcher) - lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - quickConnect := quickconnect.GetInstance() - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect) - return router -} - -func CreateAPIv1Router(ctx context.Context) *apiv1.Router { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - router := apiv1.New(dataStore) - return router -} - func CreatePublicRouter() *public.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() - imageStore := artwork.GetImageStore() fFmpeg := ffmpeg.New() - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) - transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) - router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver) + archiver := core.NewArchiver(mediaStreamer, dataStore, share) + router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) return router } @@ -180,13 +150,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router { return router } -func CreateJellyfinDiscovery() *jellyfin.Discovery { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - discovery := jellyfin.NewDiscovery(dataStore) - return discovery -} - func CreateInsights() metrics.Insights { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -204,22 +167,38 @@ func CreatePrometheus() metrics.Metrics { func CreateScanner(ctx context.Context) model.Scanner { sqlDB := db.Db() dataStore := persistence.New(sqlDB) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() broker := events.GetBroker() - uploader := artwork.NewUploader(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) return modelScanner } func CreateScanWatcher(ctx context.Context) scanner.Watcher { sqlDB := db.Db() dataStore := persistence.New(sqlDB) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() broker := events.GetBroker() - uploader := artwork.NewUploader(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher } @@ -231,32 +210,6 @@ func GetPlaybackServer() playback.PlaybackServer { return playbackServer } -func CreateArtworkWorker() *artwork.Worker { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - imageStore := artwork.GetImageStore() - broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - fFmpeg := ffmpeg.New() - fileCache := artwork.GetImageCache() - worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker, fileCache) - return worker -} - -func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - fFmpeg := ffmpeg.New() - tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live) - return tracingResolver -} - func getPluginManager() *plugins.Manager { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -268,7 +221,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index c5006e07f..bb5c5b5f3 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -14,7 +14,6 @@ import ( "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" - "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/db" @@ -23,9 +22,7 @@ import ( "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/server/apiv1" "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" @@ -36,16 +33,13 @@ var allProviders = wire.NewSet( artwork.Set, server.New, subsonic.New, - jellyfin.New, - jellyfin.NewDiscovery, - apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, - scanner.GetInstance, + scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, @@ -55,12 +49,10 @@ var allProviders = wire.NewSet( wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), - wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), - wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)), ) func CreateDataStore() model.DataStore { @@ -87,18 +79,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { )) } -func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { - panic(wire.Build( - allProviders, - )) -} - -func CreateAPIv1Router(ctx context.Context) *apiv1.Router { - panic(wire.Build( - allProviders, - )) -} - func CreatePublicRouter() *public.Router { panic(wire.Build( allProviders, @@ -117,12 +97,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router { )) } -func CreateJellyfinDiscovery() *jellyfin.Discovery { - panic(wire.Build( - allProviders, - )) -} - func CreateInsights() metrics.Insights { panic(wire.Build( allProviders, @@ -153,19 +127,6 @@ func GetPlaybackServer() playback.PlaybackServer { )) } -func CreateArtworkWorker() *artwork.Worker { - panic(wire.Build( - allProviders, - )) -} - -func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver { - panic(wire.Build( - allProviders, - artwork.NewTracingResolver, - )) -} - func getPluginManager() *plugins.Manager { panic(wire.Build( allProviders, diff --git a/conf/configtest/configtest.go b/conf/configtest/configtest.go index cd0ac41ed..b947e6263 100644 --- a/conf/configtest/configtest.go +++ b/conf/configtest/configtest.go @@ -2,7 +2,9 @@ package configtest import "github.com/navidrome/navidrome/conf" -// TODO Remove this redirection and call SnapshotConfig directly from tests func SetupConfig() func() { - return conf.SnapshotConfig() + oldValues := *conf.Server + return func() { + conf.Server = &oldValues + } } diff --git a/conf/configuration.go b/conf/configuration.go index efa9cbf9a..d93024c8a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -2,43 +2,35 @@ package conf import ( "cmp" - "encoding" - "encoding/json" "fmt" - "math" "net/url" "os" "path/filepath" - "reflect" - "regexp" "runtime" "slices" "strings" - "sync" "time" "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" "github.com/navidrome/navidrome/scheduler" "github.com/navidrome/navidrome/utils/run" - "github.com/navidrome/navidrome/utils/slice" "github.com/spf13/viper" ) type configOptions struct { - ConfigFile string `conf:"-"` + ConfigFile string Address string Port int UnixSocketPerm string EnforceNonRootUser bool MusicFolder string - DataFolder Dir - CacheFolder Dir + DataFolder string + CacheFolder string DbPath string LogLevel string LogFile string @@ -53,11 +45,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 @@ -73,7 +65,6 @@ type configOptions struct { Matcher matcherOptions `json:",omitzero"` RecentlyAddedByModTime bool PreferSortTags bool - EnableNaturalSorting bool IgnoredArticles string IndexGroups string FFmpegPath string @@ -92,7 +83,6 @@ type configOptions struct { EnableUserEditing bool EnableArtworkUpload bool MaxImageUploadSize string - MaxImageSize string EnableSharing bool ShareURL string DefaultShareExpiration time.Duration @@ -121,11 +111,9 @@ type configOptions struct { PID pidOptions `json:",omitzero"` Inspect inspectOptions `json:",omitzero"` Subsonic subsonicOptions `json:",omitzero"` - Transcoding transcodingOptions `json:",omitzero"` LastFM lastfmOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` - Jellyfin jellyfinOptions `json:",omitzero"` EnableScrobbleHistory bool Tags map[string]TagConf `json:",omitempty"` Agents string @@ -147,8 +135,6 @@ type configOptions struct { DevArtworkThrottleBacklogLimit int DevArtworkThrottleBacklogTimeout time.Duration DevArtworkThrottleBuffered bool - DevArtworkWorkerConcurrency int - DevArtworkExternalMaxRPS int DevArtistInfoTimeToLive time.Duration DevAlbumInfoTimeToLive time.Duration DevExternalScanner bool @@ -159,30 +145,22 @@ type configOptions struct { DevEnablePluginsInsights bool DevPluginCompilationTimeout time.Duration DevExternalArtistFetchMultiplier float64 + DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool - DevAPIv1 bool } type scannerOptions struct { - Enabled bool - Schedule string - WatcherWait time.Duration - ScanOnStartup bool - Extractor string - ArtistJoiner string - ArtistSplitExceptions []string // Artist names never split by tag separators - GenreSeparators string // Deprecated: Use Tags.genre.Split instead - GroupAlbumReleases bool // Deprecated: Use PID.Album instead - FollowSymlinks bool // Whether to follow symlinks when scanning directories - IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning - PurgeMissing string // Values: "never", "always", "full" -} - -type transcodingOptions struct { - MaxConcurrent int - MaxConcurrentPerUser int - EnableCancellation bool + Enabled bool + Schedule string + WatcherWait time.Duration + ScanOnStartup bool + Extractor string + ArtistJoiner string + GenreSeparators string // Deprecated: Use Tags.genre.Split instead + GroupAlbumReleases bool // Deprecated: Use PID.Album instead + FollowSymlinks bool // Whether to follow symlinks when scanning directories + PurgeMissing string // Values: "never", "always", "full" } type subsonicOptions struct { @@ -212,7 +190,7 @@ type lastfmOptions struct { ScrobbleFirstArtistOnly bool // Computed values - Languages []string `conf:"-"` // Computed from Language, split by comma + Languages []string // Computed from Language, split by comma } type deezerOptions struct { @@ -220,7 +198,7 @@ type deezerOptions struct { Language string // Computed values - Languages []string `conf:"-"` // Computed from Language, split by comma + Languages []string // Computed from Language, split by comma } type listenBrainzOptions struct { @@ -230,20 +208,6 @@ type listenBrainzOptions struct { TrackAlgorithm string } -type jellyfinOptions struct { - Enabled bool - ServerName string - // ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated - // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. - ExposedPublicUsers string - AutoDiscovery bool - QuickConnect bool - // MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB - // cursor — and its pooled connection — for the whole client-paced response, so without a bound - // enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI. - MaxConcurrentStreams int -} - type httpHeaderOptions struct { FrameOptions string } @@ -265,7 +229,7 @@ type jukeboxOptions struct { type backupOptions struct { Count int - Path Dir + Path string Schedule string } @@ -283,7 +247,7 @@ type inspectOptions struct { type pluginsOptions struct { Enabled bool - Folder Dir + Folder string CacheSize string AutoReload bool LogLevel string @@ -318,33 +282,11 @@ var currentGOOS = func() string { return runtime.GOOS } -// TLSEnabled reports whether the server serves HTTPS. Both halves are required, -// so callers cannot infer it from the certificate alone. -func (c *configOptions) TLSEnabled() bool { - return c.TLSCert != "" && c.TLSKey != "" -} - var ( Server = &configOptions{} 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() @@ -354,31 +296,18 @@ func LoadFromFile(confFile string) { Load(true) } -func durationNonNegativeOrDefault(val *time.Duration, original time.Duration) { - if val.Nanoseconds() < 0 { - log.Warn("Duration is a negative value. Using default value", "value", *val, "default", original) - *val = original - } -} - func Load(noConfigDump bool) { parseIniFileConfiguration() remapEnvVarKeysFromConfig() // Map deprecated options to their new names for backwards compatibility - for _, o := range deprecatedOptions { - if o.replacement != "" { - mapDeprecatedOption(o.name, o.replacement) - } - } + mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources") + mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader") + mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") + mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") + mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") - err := viper.Unmarshal(&Server, viper.DecodeHook( - mapstructure.ComposeDecodeHookFunc( - mapstructure.TextUnmarshallerHookFunc(), - mapstructure.StringToTimeDurationHookFunc(), - mapstructure.StringToSliceHookFunc(","), - ), - )) + err := viper.Unmarshal(&Server) if err != nil { logFatal("Error parsing config:", err) } @@ -388,29 +317,49 @@ func Load(noConfigDump bool) { logFatal(err) } - if Server.CacheFolder.String() == "" { - Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache")) + err = os.MkdirAll(Server.DataFolder, os.ModePerm) + if err != nil { + logFatal("Error creating data path:", err) + } + + if Server.CacheFolder == "" { + Server.CacheFolder = filepath.Join(Server.DataFolder, "cache") + } + err = os.MkdirAll(Server.CacheFolder, os.ModePerm) + if err != nil { + logFatal("Error creating cache path:", err) + } + + err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm) + if err != nil { + logFatal("Error creating artwork path:", err) } if Server.Plugins.Enabled { - if Server.Plugins.Folder.String() == "" { - Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700) - } else { - Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700) + if Server.Plugins.Folder == "" { + Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") + } + err = os.MkdirAll(Server.Plugins.Folder, 0700) + if err != nil { + logFatal("Error creating plugins path:", err) } } Server.ConfigFile = viper.GetViper().ConfigFileUsed() if Server.DbPath == "" { - Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath) + Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath) + } + + if Server.Backup.Path != "" { + err = os.MkdirAll(Server.Backup.Path, os.ModePerm) + if err != nil { + logFatal("Error creating backup path:", err) + } } out := os.Stderr if Server.LogFile != "" { - if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil { - logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error())) - } - out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + 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())) } @@ -428,34 +377,12 @@ func Load(noConfigDump bool) { log.SetLogSourceLine(Server.DevLogSourceLine) log.SetRedacting(Server.EnableLogRedacting) - durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout) - durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh) - durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration) - durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval) - durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength) - durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait) - - durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate) - durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout) - durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive) - durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive) - durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay) - durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout) - - // Log deprecated, removed and unknown options - for _, o := range deprecatedOptions { - logDeprecatedOptions(o.name, o.replacement) - } - logRemovedOptions(removedOptions...) - logUnknownOptions() - err = run.Sequentially( validateScanSchedule, validateBackupSchedule, validatePlaylistsPath, validatePurgeMissingOption, - validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize), - validateByteSize("MaxImageSize", Server.MaxImageSize), + validateMaxImageUploadSize, validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL), ) if err != nil { @@ -508,6 +435,20 @@ func Load(noConfigDump bool) { // Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage) Server.Deezer.Languages = parseLanguages(Server.Deezer.Language) + // Deprecated options + logDeprecatedOptions("Scanner.GenreSeparators", "") + logDeprecatedOptions("Scanner.GroupAlbumReleases", "") + logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored + logDeprecatedOptions("SearchFullString", "Search.FullString") + logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources") + logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader") + logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") + logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") + logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") + + // Removed options + logRemovedOptions("Spotify.ID", "Spotify.Secret") + // Validate other options if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 { newValue := max(200, min(1200, Server.UICoverArtSize)) @@ -515,45 +456,15 @@ func Load(noConfigDump bool) { Server.UICoverArtSize = newValue } - if Server.Scanner.Extractor != consts.DefaultScannerExtractor { - log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor) - Server.Scanner.Extractor = consts.DefaultScannerExtractor - } - - // Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back. - imgSize, _ := humanize.ParseBytes(Server.MaxImageSize) - uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize) - if imgSize < uploadSize { - log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize) - Server.MaxImageSize = Server.MaxImageUploadSize - } - // Call init hooks for _, hook := range hooks { hook() } } -// deprecatedOptions still work, but will be removed in a future release. An empty -// replacement means the option is now ignored. -var deprecatedOptions = []struct{ name, replacement string }{ - {"Scanner.GenreSeparators", ""}, - {"Scanner.GroupAlbumReleases", ""}, - {"DevEnableBufferedScrobble", ""}, - {"SearchFullString", "Search.FullString"}, - {"ReverseProxyWhitelist", "ExtAuth.TrustedSources"}, - {"ReverseProxyUserHeader", "ExtAuth.UserHeader"}, - {"HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions"}, - {"CoverJpegQuality", "CoverArtQuality"}, - {"SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold"}, - {"EnableTranscodingCancellation", "Transcoding.EnableCancellation"}, -} - -var removedOptions = []string{"Spotify.ID", "Spotify.Secret"} - func logDeprecatedOptions(oldName, newName string) { - envVar := envVarName(oldName) - newEnvVar := envVarName(newName) + envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_")) + newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_")) logWarning := func(oldName, newName string) { if newName != "" { log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName)) @@ -573,7 +484,7 @@ func logDeprecatedOptions(oldName, newName string) { // not available anymore func logRemovedOptions(options ...string) { for _, option := range options { - envVar := envVarName(option) + envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_")) logWarning := func(option string) { log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option)) } @@ -594,193 +505,35 @@ func remapEnvVarKeysFromConfig() { continue } stripped := strings.TrimPrefix(key, "nd_") - canonicalKey := ndKeyToCanonical(key) + canonicalKey := strings.ReplaceAll(stripped, "_", ".") displayNDKey := "ND_" + strings.ToUpper(stripped) - canonicalName := canonicalOptionName(canonicalKey) + displayCanonical := toPascalCase(canonicalKey) if viper.InConfig(canonicalKey) { logFatal(fmt.Sprintf( "Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+ "The 'ND_' prefix is only needed for environment variables, not config file keys.", - displayNDKey, cmp.Or(canonicalName, toPascalCase(canonicalKey)), + displayNDKey, displayCanonical, )) return } viper.Set(canonicalKey, viper.Get(key)) - // Unknown keys get no advice here, logUnknownOptions reports them instead - if canonicalName != "" { - _, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+ - "The 'ND_' prefix is only needed for environment variables.\n", - displayNDKey, canonicalName, - ) - } + _, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+ + "The 'ND_' prefix is only needed for environment variables.\n", + displayNDKey, displayCanonical, + ) } } // mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after // the config has been read by viper, but before unmarshalling it into the Config struct. func mapDeprecatedOption(legacyName, newName string) { - // viper.Set outranks the config file, so an explicit replacement must win over the legacy value - if viper.IsSet(legacyName) && !explicitlySet(newName) { + if viper.IsSet(legacyName) { viper.Set(newName, viper.Get(legacyName)) } } -// explicitlySet reports whether the user provided the option, ignoring defaults, -// which viper.IsSet counts as set. The ND_ spelling is also accepted in the config -// file, and remapEnvVarKeysFromConfig has already moved it out of InConfig's reach. -func explicitlySet(name string) bool { - envVar := envVarName(name) - return viper.InConfig(name) || os.Getenv(envVar) != "" || viper.InConfig(strings.ToLower(envVar)) -} - -func envVarName(option string) string { - if option == "" { - return "" - } - return "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_")) -} - -func logUnknownOptions() { - for _, key := range unknownConfigKeys() { - msg := fmt.Sprintf("Option '%s' is not recognized and will be ignored", key) - if matches := suggestOptions(key); len(matches) > 0 { - msg += fmt.Sprintf(". Did you mean '%s'?", strings.Join(matches, "' or '")) - } - log.Warn(msg) - } -} - -// suggestOptions returns the known options sharing the last segment with key, -// catching options written outside their section. -func suggestOptions(key string) []string { - key = strings.ToLower(key) - leaf := leafKey(key) - canonical, _ := configKeys() - var matches []string - for known, name := range canonical { - // Removed options are known only so they get their own warning, never suggest them - if known != key && leafKey(known) == leaf && !slices.Contains(removedOptions, name) { - matches = append(matches, name) - } - } - slices.Sort(matches) - return matches -} - -func leafKey(key string) string { - return key[strings.LastIndex(key, ".")+1:] -} - -// unknownConfigKeys returns config file keys that don't match any known option, so -// typos and options written outside their section don't fail silently. -func unknownConfigKeys() []string { - // INI files keep the original [default] section alongside the merged one - skipDefault := strings.EqualFold(filepath.Ext(viper.ConfigFileUsed()), ".ini") - - var unknown []string - for _, key := range viper.AllKeys() { - if !viper.InConfig(key) || canonicalOptionName(key) != "" { - continue - } - if skipDefault && strings.HasPrefix(key, "default.") { - continue - } - // Only ND_-prefixed keys that remapEnvVarKeysFromConfig could resolve are valid - if strings.HasPrefix(key, "nd_") && canonicalOptionName(ndKeyToCanonical(key)) != "" { - continue - } - unknown = append(unknown, key) - } - slices.Sort(unknown) - return asWrittenInConfigFile(unknown) -} - -func ndKeyToCanonical(key string) string { - return strings.ReplaceAll(strings.TrimPrefix(key, "nd_"), "_", ".") -} - -// canonicalOptionName returns the documented spelling of a known option key, or "" -// if it matches no option. Subkeys of free-form maps have no fixed spelling. -func canonicalOptionName(key string) string { - keys, prefixes := configKeys() - if name, ok := keys[key]; ok { - return name - } - if slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(key, p) }) { - return toPascalCase(key) - } - return "" -} - -// asWrittenInConfigFile restores the casing the keys have in the config file, as -// viper lowercases every key it loads. -func asWrittenInConfigFile(keys []string) []string { - if len(keys) == 0 { - return nil - } - data, err := os.ReadFile(viper.ConfigFileUsed()) - if err != nil { - return keys - } - casing := map[string]string{} - for _, match := range configFileKeyRx.FindAllStringSubmatch(string(data), -1) { - for segment := range strings.SplitSeq(match[1], ".") { - lower := strings.ToLower(segment) - casing[lower] = cmp.Or(casing[lower], segment) - } - } - return slice.Map(keys, func(key string) string { - segments := strings.Split(key, ".") - for i, s := range segments { - segments[i] = cmp.Or(casing[s], s) - } - return strings.Join(segments, ".") - }) -} - -// Matches keys and section headers in all supported config formats. -var configFileKeyRx = regexp.MustCompile(`(?m)^\s*\[?\s*"?([\w.]+)"?\s*[]=:]`) - -// configKeys maps every accepted option name, lowercased, to its canonical spelling, -// plus the prefixes of free-form map options (Tags, DevLogLevels). -var configKeys = sync.OnceValues(func() (map[string]string, []string) { - keys := map[string]string{} - var prefixes []string - - var collect func(t reflect.Type, prefix string) - collect = func(t reflect.Type, prefix string) { - for field := range t.Fields() { - // `conf:"-"` marks values computed during Load, not settable in the config - if !field.IsExported() || field.Tag.Get("conf") == "-" { - continue - } - name := prefix + field.Name - if field.Type.Kind() == reflect.Struct && !reflect.PointerTo(field.Type).Implements(textUnmarshalerType) { - collect(field.Type, name+".") - continue - } - lower := strings.ToLower(name) - keys[lower] = name - if field.Type.Kind() == reflect.Map { - prefixes = append(prefixes, lower+".") - } - } - } - collect(reflect.TypeFor[configOptions](), "") - - for _, o := range deprecatedOptions { - keys[strings.ToLower(o.name)] = o.name - } - for _, o := range removedOptions { - keys[strings.ToLower(o)] = o - } - return keys, prefixes -}) - -var textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() - // parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it // would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default] // section into the root level. @@ -853,20 +606,11 @@ func validatePurgeMissingOption() error { return nil } -func validateByteSize(name, value string) func() error { - return func() error { - size, err := humanize.ParseBytes(value) - if err != nil { - return fmt.Errorf("invalid %s %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", name, value, err) - } - if size == 0 { - return fmt.Errorf("invalid %s %q: must be greater than zero", name, value) - } - if size > math.MaxInt64 { - return fmt.Errorf("invalid %s %q: value is too large", name, value) - } - return nil +func validateMaxImageUploadSize() error { + if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil { + return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err) } + return nil } func validateEnforceNonRootUser() error { @@ -892,7 +636,7 @@ func validateScanSchedule() error { } func validateBackupSchedule() error { - if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { + if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { Server.Backup.Schedule = "" return nil } @@ -989,6 +733,7 @@ func setViperDefaults() { viper.SetDefault("uiwelcomemessage", "") viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists) viper.SetDefault("enabletranscodingconfig", false) + viper.SetDefault("enabletranscodingcancellation", false) viper.SetDefault("transcodingcachesize", "100MB") viper.SetDefault("imagecachesize", "100MB") viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute) @@ -996,7 +741,7 @@ func setViperDefaults() { viper.SetDefault("autoimportplaylists", true) viper.SetDefault("defaultplaylistpublicvisibility", false) viper.SetDefault("playlistspath", "") - viper.SetDefault("smartPlaylistRefreshDelay", consts.DefaultSmartRefresh) + viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second) viper.SetDefault("enabledownloads", true) viper.SetDefault("enableexternalservices", true) viper.SetDefault("enablem3uexternalalbumart", false) @@ -1009,7 +754,6 @@ func setViperDefaults() { viper.SetDefault("matcher.fuzzythreshold", 85) viper.SetDefault("recentlyaddedbymodtime", false) viper.SetDefault("prefersorttags", false) - viper.SetDefault("enablenaturalsorting", false) viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)") viper.SetDefault("ffmpegpath", "") @@ -1021,7 +765,7 @@ func setViperDefaults() { viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") viper.SetDefault("artistimagefolder", "") viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") - viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded") + viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) viper.SetDefault("enablestarrating", true) @@ -1037,17 +781,15 @@ func setViperDefaults() { viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) viper.SetDefault("enableartworkupload", true) viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) - viper.SetDefault("maximagesize", consts.DefaultMaxImageSize) - viper.SetDefault("enablesharing", true) + viper.SetDefault("enablesharing", false) viper.SetDefault("shareurl", "") - viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration) + 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", consts.DefaultAuthWindowLength) + viper.SetDefault("authwindowlength", 20*time.Second) viper.SetDefault("passwordencryptionkey", "") viper.SetDefault("extauth.userheader", "Remote-User") viper.SetDefault("extauth.trustedsources", "") @@ -1065,11 +807,9 @@ func setViperDefaults() { viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait) viper.SetDefault("scanner.scanonstartup", true) viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner) - viper.SetDefault("scanner.artistsplitexceptions", []string{}) viper.SetDefault("scanner.genreseparators", "") viper.SetDefault("scanner.groupalbumreleases", false) viper.SetDefault("scanner.followsymlinks", true) - viper.SetDefault("scanner.ignoredotfolders", true) viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever) viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.appendalbumversion", true) @@ -1078,9 +818,6 @@ func setViperDefaults() { viper.SetDefault("subsonic.enableaveragerating", true) viper.SetDefault("subsonic.legacyclients", "DSub") viper.SetDefault("subsonic.minimalclients", "SubMusic") - viper.SetDefault("transcoding.maxconcurrent", 0) - viper.SetDefault("transcoding.maxconcurrentperuser", 0) - viper.SetDefault("transcoding.enablecancellation", false) viper.SetDefault("agents", "deezer,lastfm,listenbrainz") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) @@ -1093,10 +830,6 @@ func setViperDefaults() { viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL) viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm) viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm) - viper.SetDefault("jellyfin.enabled", false) - viper.SetDefault("jellyfin.servername", "") - viper.SetDefault("jellyfin.autodiscovery", false) - viper.SetDefault("jellyfin.quickconnect", true) viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") @@ -1125,21 +858,11 @@ func setViperDefaults() { viper.SetDefault("devshowartistpage", true) viper.SetDefault("devuishowconfig", true) viper.SetDefault("devneweventstream", true) - viper.SetDefault("devapiv1", false) viper.SetDefault("devoffsetoptimize", 50000) - // Half the pool: streams may take up to this many connections, leaving the rest for the scanner, - // scrobbles and the UI. See MaxOpenConns. - viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2)) viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartworkthrottlebuffered", true) - // Half the CPU count (min 2), so local resolution scales with the host but stays under the - // SQLite pool (MaxOpenConns) — leaving connections for the scanner, scrobbles and the UI. - viper.SetDefault("devartworkworkerconcurrency", max(2, runtime.NumCPU()/2)) - // External RPS gates outbound calls to third-party services (per service); it is bounded by - // their tolerance, not the host, so it stays a small constant regardless of CPU count. - viper.SetDefault("devartworkexternalmaxrps", 2) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive) viper.SetDefault("devexternalscanner", true) @@ -1150,6 +873,7 @@ func setViperDefaults() { viper.SetDefault("devenablepluginsinsights", true) viper.SetDefault("devplugincompilationtimeout", time.Minute) viper.SetDefault("devexternalartistfetchmultiplier", 1.5) + viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devenablemediafileprobe", true) } @@ -1206,14 +930,3 @@ func getConfigFile(cfgFile string) string { } return "" } - -// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner, -// Subsonic, Jellyfin, native API, UI). -// -// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so -// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a -// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the -// count is only loosely related to core count, and the floor is what matters on small machines. -func MaxOpenConns() int { - return max(4, runtime.NumCPU()) -} diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 8c4c8ab86..5d4e73fad 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -1,18 +1,12 @@ package conf_test import ( - "bytes" "fmt" "os" "path/filepath" - "runtime" "testing" - "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/spf13/viper" @@ -64,19 +58,6 @@ var _ = Describe("Configuration", func() { }) }) - Describe("scheduled DB analysis", func() { - It("is enabled by default", func() { - conf.Load(true) - Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue()) - }) - - It("can be disabled", func() { - viper.Set("enablescheduleddbanalyze", false) - conf.Load(true) - Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse()) - }) - }) - Describe("ValidateURL", func() { It("accepts a valid http URL", func() { fn := conf.ValidateURL("TestOption", "http://example.com/path") @@ -184,123 +165,6 @@ var _ = Describe("Configuration", func() { }) }) - Describe("unknownConfigKeys", func() { - BeforeEach(func() { - viper.Reset() - conf.SetViperDefaults() - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("loglevel", "error") - conf.ResetConf() - }) - - It("reports misplaced and misspelled options, as spelled in the config file", func() { - conf.InitConfig(filepath.Join("testdata", "cfg_unknown_keys.toml"), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(ConsistOf( - "ArtistSplitExceptions", "EnableDownlods", "Whatever.Foo", - )) - }) - - DescribeTable("recovers the original casing in all supported formats", - func(file string) { - conf.InitConfig(filepath.Join("testdata", file), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(ConsistOf("NotAnOption")) - }, - Entry("TOML", "cfg_unknown_casing.toml"), - Entry("YAML", "cfg_unknown_casing.yaml"), - Entry("JSON", "cfg_unknown_casing.json"), - Entry("INI", "cfg_unknown_casing.ini"), - ) - - It("does not report valid, deprecated or free-form keys", func() { - conf.InitConfig(filepath.Join("testdata", "cfg.toml"), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(BeEmpty()) - }) - - It("does not report the [default] section of INI files", func() { - conf.InitConfig(filepath.Join("testdata", "cfg.ini"), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(BeEmpty()) - }) - - DescribeTable("SuggestOptions", - func(key string, expected []string) { - Expect(conf.SuggestOptions(key)).To(Equal(expected)) - }, - Entry("suggests the section of a misplaced option", "artistsplitexceptions", - []string{"Scanner.ArtistSplitExceptions"}), - Entry("suggests the section of a misplaced nested option", "backup.fuzzythreshold", - []string{"Matcher.FuzzyThreshold"}), - Entry("suggests every section defining the option", "schedule", - []string{"Backup.Schedule", "Scanner.Schedule"}), - Entry("suggests nothing for a typo", "enabledownlods", nil), - ) - - It("does not report ND_-prefixed keys, as they are remapped", func() { - conf.InitConfig(filepath.Join("testdata", "cfg_nd_keys.toml"), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(BeEmpty()) - }) - - It("reports ND_-prefixed keys that remap to no known option", func() { - conf.InitConfig(filepath.Join("testdata", "cfg_nd_bogus.toml"), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(ConsistOf("ND_TOTALLY_BOGUS_OPTION")) - Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h")) - }) - - It("migrates every deprecated option that has a replacement", func() { - conf.InitConfig(filepath.Join("testdata", "cfg_deprecated_search.toml"), false) - conf.Load(true) - - Expect(conf.Server.Search.FullString).To(BeTrue()) - Expect(conf.UnknownConfigKeys()).To(BeEmpty()) - }) - - It("warns about each unrecognized option at startup", func() { - var logBuf bytes.Buffer - log.SetOutput(&logBuf) - DeferCleanup(func() { log.SetOutput(GinkgoWriter) }) - - conf.InitConfig(filepath.Join("testdata", "cfg_warning_output.toml"), false) - conf.Load(true) - - Expect(logBuf.String()).To(ContainSubstring( - "Option 'ArtistSplitExceptions' is not recognized and will be ignored. " + - "Did you mean 'Scanner.ArtistSplitExceptions'?")) - Expect(logBuf.String()).To(ContainSubstring( - "Option 'EnableDownlods' is not recognized and will be ignored")) - Expect(logBuf.String()).ToNot(ContainSubstring("ArtistJoiner")) - }) - - Context("with runtime-computed and removed options in the config", func() { - BeforeEach(func() { - conf.InitConfig(filepath.Join("testdata", "cfg_runtime_fields.toml"), false) - conf.Load(true) - }) - - It("reports values computed during Load, which the config cannot set", func() { - Expect(conf.UnknownConfigKeys()).To(ContainElements("ConfigFile", "LastFM.Languages")) - }) - - It("never suggests a removed option", func() { - Expect(conf.SuggestOptions("id")).To(BeEmpty()) - }) - - It("keeps an explicit replacement over the deprecated value", func() { - Expect(conf.Server.Search.FullString).To(BeFalse()) - }) - }) - }) - Describe("logFatal", func() { var invalidPath string BeforeEach(func() { @@ -322,27 +186,27 @@ var _ = Describe("Configuration", func() { }).To(PanicWith(ContainSubstring("Error reading config file"))) }) + It("is called when DataFolder is not writable", func() { + viper.SetDefault("datafolder", invalidPath) + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("Error creating data path"))) + }) + + It("is called when CacheFolder is not writable", func() { + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("cachefolder", invalidPath) + Expect(func() { + conf.Load(true) + }).To(PanicWith(ContainSubstring("Error creating cache path"))) + }) + It("is called when LogFile path is not writable", func() { viper.SetDefault("datafolder", GinkgoT().TempDir()) viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt")) Expect(func() { conf.Load(true) - }).To(PanicWith(ContainSubstring("Error creating log file directory"))) - }) - - It("creates the log file readable only by the owner", func() { - if runtime.GOOS == "windows" { - Skip("file modes are not enforced on Windows") - } - logFile := filepath.Join(GinkgoT().TempDir(), "navidrome.log") - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("logfile", logFile) - DeferCleanup(log.SetOutput, os.Stderr) - conf.Load(true) - - info, err := os.Stat(logFile) - Expect(err).ToNot(HaveOccurred()) - Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600))) + }).To(PanicWith(ContainSubstring("Error opening log file"))) }) It("is called when BaseURL is invalid", func() { @@ -355,10 +219,19 @@ var _ = Describe("Configuration", func() { }) - Describe("ValidateByteSize", func() { + Describe("ValidateMaxImageUploadSize", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + DescribeTable("accepts valid size values", func(input string) { - Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(Succeed()) + conf.Server.MaxImageUploadSize = input + Expect(conf.ValidateMaxImageUploadSize()).To(Succeed()) }, Entry("megabytes", "10MB"), Entry("gigabytes", "1GB"), @@ -369,60 +242,14 @@ var _ = Describe("Configuration", func() { DescribeTable("rejects invalid size values", func(input string) { - Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize"))) + conf.Server.MaxImageUploadSize = input + Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize"))) }, Entry("garbage string", "not-a-size"), Entry("negative-looking", "-10MB"), - Entry("zero", "0"), - Entry("zero with unit", "0MB"), - Entry("overflows int64", "9223372036854775808"), ) }) - Describe("MaxImageSize floor", func() { - BeforeEach(func() { - viper.Reset() - conf.SetViperDefaults() - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("loglevel", "error") - conf.ResetConf() - }) - - It("is raised to MaxImageUploadSize when configured lower", func() { - viper.SetDefault("maximagesize", "5MB") - viper.SetDefault("maximageuploadsize", "50MB") - conf.Load(true) - Expect(conf.Server.MaxImageSize).To(Equal("50MB")) - }) - - It("keeps a larger MaxImageSize unchanged", func() { - viper.SetDefault("maximagesize", "30MB") - conf.Load(true) - Expect(conf.Server.MaxImageSize).To(Equal("30MB")) - }) - }) - - Describe("Scanner.Extractor", func() { - BeforeEach(func() { - viper.Reset() - conf.SetViperDefaults() - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("loglevel", "error") - conf.ResetConf() - }) - - It("falls back to taglib for an unknown extractor", func() { - viper.SetDefault("scanner.extractor", "ffmpeg") - conf.Load(true) - Expect(conf.Server.Scanner.Extractor).To(Equal("taglib")) - }) - - It("keeps taglib", func() { - conf.Load(true) - Expect(conf.Server.Scanner.Extractor).To(Equal("taglib")) - }) - }) - Describe("EnforceNonRootUser", func() { It("defaults to false", func() { conf.Load(true) @@ -492,73 +319,4 @@ var _ = Describe("Configuration", func() { Entry("INI format", "ini"), Entry("JSON format", "json"), ) - - It("should use default values for negative duration fields", func() { - filename := filepath.Join("testdata", "invalid_duration.toml") - conf.InitConfig(filename, false) - conf.Load(true) - - server := conf.Server - Expect(server.SessionTimeout).To(Equal(consts.DefaultSessionTimeout)) - Expect(server.SmartPlaylistRefreshDelay).To(Equal(consts.DefaultSmartRefresh)) - Expect(server.DefaultShareExpiration).To(Equal(consts.DefaultShareExpiration)) - Expect(server.UIPlaybackReportInterval).To(Equal(consts.DefaultUIPlaybackReportInterval)) - Expect(server.AuthWindowLength).To(Equal(consts.DefaultAuthWindowLength)) - Expect(server.Scanner.WatcherWait).To(Equal(consts.DefaultWatcherWait)) - - Expect(server.DevActivityPanelUpdateRate).To(Equal(consts.DefaultActivityPanelUpdateRate)) - Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(consts.RequestThrottleBacklogTimeout)) - Expect(server.DevArtistInfoTimeToLive).To(Equal(consts.ArtistInfoTimeToLive)) - Expect(server.DevAlbumInfoTimeToLive).To(Equal(consts.AlbumInfoTimeToLive)) - Expect(server.DevInsightsInitialDelay).To(Equal(consts.InsightsInitialDelay)) - Expect(server.DevPluginCompilationTimeout).To(Equal(consts.DefaultPluginCompilationTimeout)) - }) - - It("should use parsed values for duration fields", func() { - conf.InitConfig(filepath.Join("testdata", "valid_duration.toml"), false) - conf.Load(true) - - configured := 1 * time.Second - - server := conf.Server - Expect(server.SessionTimeout).To(Equal(configured)) - Expect(server.SmartPlaylistRefreshDelay).To(Equal(configured)) - Expect(server.DefaultShareExpiration).To(Equal(configured)) - Expect(server.UIPlaybackReportInterval).To(Equal(configured)) - Expect(server.AuthWindowLength).To(Equal(configured)) - Expect(server.Scanner.WatcherWait).To(Equal(configured)) - - Expect(server.DevActivityPanelUpdateRate).To(Equal(configured)) - Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(configured)) - Expect(server.DevArtistInfoTimeToLive).To(Equal(configured)) - Expect(server.DevAlbumInfoTimeToLive).To(Equal(configured)) - Expect(server.DevInsightsInitialDelay).To(Equal(configured)) - Expect(server.DevPluginCompilationTimeout).To(Equal(configured)) - }) -}) - -var _ = Describe("TLSEnabled", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("is false when neither the certificate nor the key is set", func() { - Expect(conf.Server.TLSEnabled()).To(BeFalse()) - }) - - It("is true when both the certificate and the key are set", func() { - conf.Server.TLSCert = "cert.pem" - conf.Server.TLSKey = "key.pem" - Expect(conf.Server.TLSEnabled()).To(BeTrue()) - }) - - It("is false when only the certificate is set", func() { - conf.Server.TLSCert = "cert.pem" - Expect(conf.Server.TLSEnabled()).To(BeFalse()) - }) - - It("is false when only the key is set", func() { - conf.Server.TLSKey = "key.pem" - Expect(conf.Server.TLSEnabled()).To(BeFalse()) - }) }) diff --git a/conf/dir.go b/conf/dir.go deleted file mode 100644 index f7a14b933..000000000 --- a/conf/dir.go +++ /dev/null @@ -1,77 +0,0 @@ -package conf - -import ( - "cmp" - "fmt" - "os" -) - -// Dir wraps a directory path and creates the directory on demand. Dir is a -// plain value type — safe to copy, compare, and print via reflection-based -// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards. -// Directory creation is delegated to os.MkdirAll on every Path() call; -// MkdirAll is idempotent, so repeated calls cost one stat syscall when the -// directory already exists. -type Dir struct { - path string - perm os.FileMode -} - -// NewDir creates a new Dir with the given path and default permissions (os.ModePerm). -func NewDir(path string) Dir { - return Dir{path: path, perm: os.ModePerm} -} - -// NewDirWithPerm creates a new Dir with the given path and permissions. -// A perm of 0 is treated as "default" and resolves to os.ModePerm at -// directory-creation time; pass an explicit non-zero mode to constrain the -// permissions. -func NewDirWithPerm(path string, perm os.FileMode) Dir { - return Dir{path: path, perm: perm} -} - -// String returns the raw path without creating the directory. Satisfies fmt.Stringer. -func (d Dir) String() string { - return d.path -} - -// Path ensures the directory exists and returns its path. Safe to call -// repeatedly; an empty path is returned as-is with no error. -func (d Dir) Path() (string, error) { - if d.path == "" { - return "", nil - } - if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil { - return d.path, fmt.Errorf("creating directory %q: %w", d.path, err) - } - return d.path, nil -} - -// MustPath calls Path() and calls logFatal on error. -func (d Dir) MustPath() string { - path, err := d.Path() - if err != nil { - logFatal("creating directory:", err) - } - return path -} - -// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf) -// prints the path string instead of the internal struct fields. -func (d Dir) GoString() string { - return fmt.Sprintf("%q", d.path) -} - -// MarshalText returns the raw path bytes. No side effects. -func (d Dir) MarshalText() ([]byte, error) { - return []byte(d.path), nil -} - -// UnmarshalText sets the path from bytes. No side effects. -func (d *Dir) UnmarshalText(text []byte) error { - d.path = string(text) - if d.perm == 0 { - d.perm = os.ModePerm - } - return nil -} diff --git a/conf/dir_test.go b/conf/dir_test.go deleted file mode 100644 index 79db379d2..000000000 --- a/conf/dir_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package conf_test - -import ( - "os" - "sync" - - "github.com/kr/pretty" - "github.com/navidrome/navidrome/conf" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Dir", func() { - Describe("NewDir", func() { - It("creates a Dir with the given path without side effects", func() { - d := conf.NewDir("/some/path") - Expect(d.String()).To(Equal("/some/path")) - }) - }) - - Describe("String", func() { - It("returns the raw path without creating the directory", func() { - d := conf.NewDir("/nonexistent/path/that/should/not/be/created") - Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created")) - }) - }) - - Describe("Path", func() { - It("creates the directory and returns the path on first call", func() { - dir := GinkgoT().TempDir() - target := dir + "/subdir/nested" - d := conf.NewDir(target) - - path, err := d.Path() - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(target)) - Expect(target).To(BeADirectory()) - }) - - It("is idempotent on subsequent calls", func() { - dir := GinkgoT().TempDir() - target := dir + "/idempotent" - d := conf.NewDir(target) - - path1, err1 := d.Path() - path2, err2 := d.Path() - Expect(err1).ToNot(HaveOccurred()) - Expect(err2).ToNot(HaveOccurred()) - Expect(path1).To(Equal(path2)) - Expect(target).To(BeADirectory()) - }) - - It("returns an error when directory cannot be created", func() { - f := GinkgoT().TempDir() - blocker := f + "/blocker" - By("creating a file that blocks directory creation") - Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed()) - invalid := blocker + "/subdir" - - d := conf.NewDir(invalid) - _, pathErr := d.Path() - Expect(pathErr).To(HaveOccurred()) - }) - - It("returns empty path and no error for empty path", func() { - d := conf.NewDir("") - path, err := d.Path() - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(BeEmpty()) - }) - }) - - Describe("MustPath", func() { - It("returns the path when directory is created successfully", func() { - dir := GinkgoT().TempDir() - target := dir + "/mustpath" - d := conf.NewDir(target) - - path := d.MustPath() - Expect(path).To(Equal(target)) - Expect(target).To(BeADirectory()) - }) - - It("calls logFatal on error", func() { - var fatalMsg []any - restore := conf.SetLogFatal(func(args ...any) { - fatalMsg = args - panic("logFatal called") - }) - DeferCleanup(restore) - - f := GinkgoT().TempDir() + "/blocker" - Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed()) - invalid := f + "/subdir" - - d := conf.NewDir(invalid) - Expect(func() { d.MustPath() }).To(Panic()) - Expect(fatalMsg).ToNot(BeEmpty()) - }) - }) - - Describe("MarshalText", func() { - It("returns the raw path bytes without side effects", func() { - d := conf.NewDir("/marshal/path") - b, err := d.MarshalText() - Expect(err).ToNot(HaveOccurred()) - Expect(string(b)).To(Equal("/marshal/path")) - }) - }) - - Describe("UnmarshalText", func() { - It("sets the path from bytes without side effects", func() { - d := conf.NewDir("") - err := d.UnmarshalText([]byte("/unmarshal/path")) - Expect(err).ToNot(HaveOccurred()) - Expect(d.String()).To(Equal("/unmarshal/path")) - }) - - It("allows round-trip marshal/unmarshal", func() { - d1 := conf.NewDir("/round/trip") - b, err := d1.MarshalText() - Expect(err).ToNot(HaveOccurred()) - - var d2 conf.Dir - err = d2.UnmarshalText(b) - Expect(err).ToNot(HaveOccurred()) - Expect(d2.String()).To(Equal(d1.String())) - }) - }) - - Describe("GoString", func() { - // Regression: pretty.Sprintf("%# v", ...) is used by the - // configuration dump. It must render Dir as a quoted path via - // GoString, not dump the internal struct fields. - It("renders Dir as a quoted path under pretty.Sprintf", func() { - type host struct { - DataFolder conf.Dir - } - h := host{DataFolder: conf.NewDir("./data")} - out := pretty.Sprintf("%# v", h) - Expect(out).To(ContainSubstring(`DataFolder: "./data"`)) - Expect(out).ToNot(ContainSubstring("perm:")) - Expect(out).ToNot(ContainSubstring("path:")) - }) - - It("is safe to copy and use concurrently", func() { - // Regression for the Windows "sync: unlock of unlocked mutex" - // crash that was caused by copying a Dir embedding sync.Once. - // Dir is a plain value type now, but keep the concurrent stress - // test to lock in the property. - dir := GinkgoT().TempDir() - d := conf.NewDir(dir + "/race") - var wg sync.WaitGroup - for range 10 { - wg.Go(func() { - copy1 := d - _ = pretty.Sprintf("%# v", copy1) - _, _ = copy1.Path() - }) - } - wg.Wait() - }) - }) -}) diff --git a/conf/export_test.go b/conf/export_test.go index d1e1a6f99..acebca551 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -14,7 +14,7 @@ var NormalizeSearchBackend = normalizeSearchBackend var ToPascalCase = toPascalCase -var ValidateByteSize = validateByteSize +var ValidateMaxImageUploadSize = validateMaxImageUploadSize func SetRuntimeInfoForTest(goos string, euid int) func() { oldGOOS := currentGOOS @@ -32,7 +32,3 @@ func SetLogFatal(f func(...any)) func() { logFatal = f return func() { logFatal = old } } - -var UnknownConfigKeys = unknownConfigKeys - -var SuggestOptions = suggestOptions diff --git a/conf/testdata/cfg_deprecated_search.toml b/conf/testdata/cfg_deprecated_search.toml deleted file mode 100644 index cc6541e09..000000000 --- a/conf/testdata/cfg_deprecated_search.toml +++ /dev/null @@ -1,2 +0,0 @@ -MusicFolder = "/toml/music" -SearchFullString = true diff --git a/conf/testdata/cfg_nd_bogus.toml b/conf/testdata/cfg_nd_bogus.toml deleted file mode 100644 index 841b40998..000000000 --- a/conf/testdata/cfg_nd_bogus.toml +++ /dev/null @@ -1,3 +0,0 @@ -MusicFolder = "/toml/music" -ND_TOTALLY_BOGUS_OPTION = true -ND_SCANNER_SCHEDULE = "@every 1h" diff --git a/conf/testdata/cfg_runtime_fields.toml b/conf/testdata/cfg_runtime_fields.toml deleted file mode 100644 index a9c09f619..000000000 --- a/conf/testdata/cfg_runtime_fields.toml +++ /dev/null @@ -1,10 +0,0 @@ -MusicFolder = "/toml/music" -SearchFullString = true -ConfigFile = "/somewhere/else" -ID = "oops" - -[Search] -FullString = false - -[LastFM] -Languages = ["pt"] diff --git a/conf/testdata/cfg_unknown_casing.ini b/conf/testdata/cfg_unknown_casing.ini deleted file mode 100644 index c88db9b1f..000000000 --- a/conf/testdata/cfg_unknown_casing.ini +++ /dev/null @@ -1,3 +0,0 @@ -[default] -MusicFolder = /ini/music -NotAnOption = true diff --git a/conf/testdata/cfg_unknown_casing.json b/conf/testdata/cfg_unknown_casing.json deleted file mode 100644 index cd3a226cd..000000000 --- a/conf/testdata/cfg_unknown_casing.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "MusicFolder": "/json/music", - "NotAnOption": true -} diff --git a/conf/testdata/cfg_unknown_casing.toml b/conf/testdata/cfg_unknown_casing.toml deleted file mode 100644 index f015c9cb2..000000000 --- a/conf/testdata/cfg_unknown_casing.toml +++ /dev/null @@ -1,2 +0,0 @@ -MusicFolder = "/toml/music" -NotAnOption = true diff --git a/conf/testdata/cfg_unknown_casing.yaml b/conf/testdata/cfg_unknown_casing.yaml deleted file mode 100644 index 8987f6351..000000000 --- a/conf/testdata/cfg_unknown_casing.yaml +++ /dev/null @@ -1,2 +0,0 @@ -MusicFolder: /yaml/music -NotAnOption: true diff --git a/conf/testdata/cfg_unknown_keys.toml b/conf/testdata/cfg_unknown_keys.toml deleted file mode 100644 index a7ed55cb0..000000000 --- a/conf/testdata/cfg_unknown_keys.toml +++ /dev/null @@ -1,18 +0,0 @@ -MusicFolder = "/toml/music" - -# Valid option, but written at the root level instead of under Scanner -ArtistSplitExceptions = ["AC/DC", "Tyler, the creator"] - -# Misspelled option -EnableDownlods = true - -# Unknown section -[Whatever] -Foo = "bar" - -# Valid options, must not be reported -[Scanner] -ArtistJoiner = " • " - -[Tags.custom] -aliases = ["toml", "test"] diff --git a/conf/testdata/cfg_warning_output.toml b/conf/testdata/cfg_warning_output.toml deleted file mode 100644 index a52c5ea54..000000000 --- a/conf/testdata/cfg_warning_output.toml +++ /dev/null @@ -1,7 +0,0 @@ -MusicFolder = "/toml/music" -LogLevel = "warn" -ArtistSplitExceptions = ["AC/DC"] -EnableDownlods = true - -[Scanner] -ArtistJoiner = " • " diff --git a/conf/testdata/invalid_duration.toml b/conf/testdata/invalid_duration.toml deleted file mode 100644 index 6540fef60..000000000 --- a/conf/testdata/invalid_duration.toml +++ /dev/null @@ -1,12 +0,0 @@ -SessionTimeout = "-10s" -SmartPlaylistRefreshDelay = "-10s" -UIPlaybackReportInterval = "-10s" -AuthWindowLength = "-10s" -DefaultShareExpiration = "-10s" -Scanner.WatcherWait = "-10s" -DevActivityPanelUpdateRate = "-10s" -DevArtworkThrottleBacklogTimeout = "-10s" -DevArtistInfoTimeToLive = "-10s" -DevAlbumInfoTimeToLive = "-10s" -DevInsightsInitialDelay = "-10s" -DevPluginCompilationTimeout = "-10s" diff --git a/conf/testdata/valid_duration.toml b/conf/testdata/valid_duration.toml deleted file mode 100644 index 6ec4af480..000000000 --- a/conf/testdata/valid_duration.toml +++ /dev/null @@ -1,12 +0,0 @@ -SessionTimeout = "1s" -SmartPlaylistRefreshDelay = "1s" -UIPlaybackReportInterval = "1s" -AuthWindowLength = "1s" -DefaultShareExpiration = "1s" -Scanner.WatcherWait = "1s" -DevActivityPanelUpdateRate = "1s" -DevArtworkThrottleBacklogTimeout = "1s" -DevArtistInfoTimeToLive = "1s" -DevAlbumInfoTimeToLive = "1s" -DevInsightsInitialDelay = "1s" -DevPluginCompilationTimeout = "1s" diff --git a/consts/consts.go b/consts/consts.go index 42e9ec42f..bf32006d6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -14,42 +14,24 @@ 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" - // ArtConfFingerprintPropertyKey is the model.PropertyRepository key the artwork config check - // compares against to detect artwork-affecting config changes across restarts. - ArtConfFingerprintPropertyKey = "ArtConfFingerprint" UIAuthorizationHeader = "X-ND-Authorization" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" JWTSecretKey = "JWTSecret" - JWTPublicSecretKey = "JWTPublicSecret" JWTIssuer = "ND" DefaultSessionTimeout = 48 * time.Hour - DefaultSmartRefresh = 5 * time.Second - DefaultShareExpiration = 8760 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year - DBAnalyzeCheckSchedule = "@every 30m" - DBAnalyzeMaxAge = 24 * time.Hour - - ArtworkEnqueueMissingSchedule = "@every 1h" - ArtworkPruneSchedule = "@daily" + OptimizeDBSchedule = "@every 24h" // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option DefaultEncryptionKey = "just for obfuscation" PasswordsEncryptedKey = "PasswordsEncryptedKey" PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec - APIKeyPrefix = "nds_" DevInitialUserName = "admin" DevInitialName = "Dev Admin" @@ -59,12 +41,6 @@ const ( URLPathSubsonicAPI = "/rest" URLPathPublic = "/share" URLPathPublicImages = URLPathPublic + "/img" - URLPathJellyfinAPI = "/jellyfin" - URLPathAPIv1 = "/api/v1" - - // JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the - // Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts. - JellyfinServerIDKey = "JellyfinServerID" // DefaultUILoginBackgroundURL uses Navidrome curated background images collection, // available at https://unsplash.com/collections/20072696/navidrome @@ -75,7 +51,6 @@ const ( DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline DefaultMaxSidebarPlaylists = 100 - DefaultAuthWindowLength = 20 * time.Second RequestThrottleBacklogLimit = 100 RequestThrottleBacklogTimeout = time.Minute @@ -91,9 +66,6 @@ const ( I18nFolder = "i18n" ScanIgnoreFile = ".ndignore" ArtworkFolder = "artwork" - // HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed - // upload folders beside it so Prune's sweep never reaches them. - HashedArtworkFolder = "hashed" PlaceholderArtistArt = "artist-placeholder.webp" PlaceholderAlbumArt = "album-placeholder.webp" @@ -111,15 +83,11 @@ const ( DefaultScannerExtractor = "taglib" DefaultWatcherWait = 5 * time.Second Zwsp = string('\u200b') - - DefaultActivityPanelUpdateRate = 300 * time.Millisecond - DefaultPluginCompilationTimeout = time.Minute ) const ( DefaultUICoverArtSize = 300 DefaultMaxImageUploadSize = "10MB" - DefaultMaxImageSize = "20MB" ) // Prometheus options @@ -156,6 +124,8 @@ const ( //DefaultAlbumPID = "album_legacy" DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title" + PIDAlbumKey = "PIDAlbum" + PIDTrackKey = "PIDTrack" ) const ( @@ -183,30 +153,30 @@ var ( Name: "mp3 audio", TargetFormat: "mp3", DefaultBitRate: 192, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -", + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", }, { Name: "opus audio", TargetFormat: "opus", DefaultBitRate: 128, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", }, { Name: "aac audio", TargetFormat: "aac", DefaultBitRate: 256, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", }, { Name: "flac audio", TargetFormat: "flac", DefaultBitRate: 0, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -", + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", }, } ) -var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome" +var HTTPUserAgent = "Navidrome" + "/" + Version var ( VariousArtists = "Various Artists" diff --git a/contrib/navidrome b/contrib/navidrome index 8ab5afe2e..bb6908797 100644 --- a/contrib/navidrome +++ b/contrib/navidrome @@ -2,7 +2,7 @@ name=$RC_SVCNAME command="/opt/navidrome/${RC_SVCNAME}" -command_args="--datafolder /opt/navidrome" +command_args="-datafolder /opt/navidrome" command_user="${RC_SVCNAME}" pidfile="/var/run/${RC_SVCNAME}.pid" output_log="/opt/navidrome/${RC_SVCNAME}.log" diff --git a/contrib/navidrome.service b/contrib/navidrome.service index ef61d2c65..5e6cbedce 100644 --- a/contrib/navidrome.service +++ b/contrib/navidrome.service @@ -36,7 +36,7 @@ RestrictNamespaces=yes RestrictRealtime=yes SystemCallFilter=@system-service SystemCallFilter=~@privileged @resources -SystemCallFilter=setrlimit mbind +SystemCallFilter=setrlimit SystemCallArchitectures=native UMask=0066 diff --git a/core/agents/agents.go b/core/agents/agents.go index 8ae3124ef..ead6dacd0 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -1,13 +1,9 @@ package agents import ( - "cmp" "context" - "errors" - "maps" "slices" "strings" - "sync" "time" "github.com/navidrome/navidrome/conf" @@ -26,43 +22,11 @@ type PluginLoader interface { LoadMediaAgent(name string) (Interface, bool) } -// agentCooldown is the default cooldown duration for an agent that returns a RetryLaterError without a specific -// RetryIn duration. -const agentCooldown = time.Minute - -// errUnsupported marks an agent that does not implement the requested method: it never ran, -// so it neither answered nor throttled. -var errUnsupported = errors.New("agent does not support this method") - // Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order // until one returns valid data. type Agents struct { ds model.DataStore pluginLoader PluginLoader - cooldowns cooldowns -} - -// cooldowns remembers, across dispatches, which agents asked to be left alone and until when. -type cooldowns struct { - mu sync.RWMutex - until map[string]time.Time -} - -func (c *cooldowns) active(name string) bool { - c.mu.RLock() - defer c.mu.RUnlock() - return time.Now().Before(c.until[name]) -} - -// park keeps whichever deadline is later, so a call still in flight when a longer cooldown -// starts cannot cut it short when it finally answers. -func (c *cooldowns) park(name string, d time.Duration) { - until := time.Now().Add(d) - c.mu.Lock() - defer c.mu.Unlock() - if until.After(c.until[name]) { - c.until[name] = until - } } // GetAgents returns the singleton instance of Agents @@ -77,7 +41,6 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents { return &Agents{ ds: ds, pluginLoader: pluginLoader, - cooldowns: cooldowns{until: map[string]time.Time{}}, } } @@ -127,19 +90,12 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent { } else if isPlugin { validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true}) } else { - log.Debug("Unknown agent ignored", "name", name, "available", availableAgentNames(availablePlugins)) + log.Debug("Unknown agent ignored", "name", name) } } return validAgents } -// availableAgentNames returns every name accepted by the Agents config option. -func availableAgentNames(plugins []string) []string { - names := append(slices.Collect(maps.Keys(Map)), plugins...) - slices.Sort(names) - return names -} - func (a *Agents) getAgent(ea enabledAgent) Interface { if ea.isPlugin { // Try to load WASM plugin agent (if plugin loader is available) @@ -168,42 +124,6 @@ func (a *Agents) AgentName() string { return "agents" } -// ArtistImageAgent pairs an enabled agent's name with its ArtistImageRetriever capability. -type ArtistImageAgent struct { - Name string - Retriever ArtistImageRetriever -} - -// AlbumImageAgent pairs an enabled agent's name with its AlbumImageRetriever capability. -type AlbumImageAgent struct { - Name string - Retriever AlbumImageRetriever -} - -// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever, -// in conf.Server.Agents order (same order the aggregate dispatch uses). -func (a *Agents) ArtistImageAgents() []ArtistImageAgent { - var result []ArtistImageAgent - for _, ea := range a.getEnabledAgentNames() { - if retriever, ok := a.getAgent(ea).(ArtistImageRetriever); ok { - result = append(result, ArtistImageAgent{Name: ea.name, Retriever: retriever}) - } - } - return result -} - -// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever, -// in conf.Server.Agents order (same order the aggregate dispatch uses). -func (a *Agents) AlbumImageAgents() []AlbumImageAgent { - var result []AlbumImageAgent - for _, ea := range a.getEnabledAgentNames() { - if retriever, ok := a.getAgent(ea).(AlbumImageRetriever); ok { - result = append(result, AlbumImageAgent{Name: ea.name, Retriever: retriever}) - } - } - return result -} - func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { switch id { case consts.UnknownArtistID: @@ -215,7 +135,7 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistMBIDRetriever) if !ok { - return "", errUnsupported + return "", ErrNotFound } return retriever.GetArtistMBID(ctx, id, name) }) @@ -232,7 +152,7 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistURLRetriever) if !ok { - return "", errUnsupported + return "", ErrNotFound } return retriever.GetArtistURL(ctx, id, name, mbid) }) @@ -249,7 +169,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistBiographyRetriever) if !ok { - return "", errUnsupported + return "", ErrNotFound } return retriever.GetArtistBiography(ctx, id, name, mbid) }) @@ -268,11 +188,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier) start := time.Now() - attempts := newAttempts(&a.cooldowns) for _, enabledAgent := range a.getEnabledAgentNames() { - if attempts.skip(enabledAgent.name) { - continue - } ag := a.getAgent(enabledAgent) if ag == nil { continue @@ -285,7 +201,6 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l continue } similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit) - attempts.record(enabledAgent.name, err) if len(similar) > 0 && err == nil { if log.IsGreaterOrEqualTo(log.LevelTrace) { log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) @@ -295,7 +210,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l return similar, err } } - return nil, attempts.noResultErr() + return nil, ErrNotFound } func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) { @@ -309,7 +224,7 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([] return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) { retriever, ok := ag.(ArtistImageRetriever) if !ok { - return nil, errUnsupported + return nil, ErrNotFound } return retriever.GetArtistImages(ctx, id, name, mbid) }) @@ -330,7 +245,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) { retriever, ok := ag.(ArtistTopSongsRetriever) if !ok { - return nil, errUnsupported + return nil, ErrNotFound } return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit) }) @@ -344,7 +259,7 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (* return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) { retriever, ok := ag.(AlbumInfoRetriever) if !ok { - return nil, errUnsupported + return nil, ErrNotFound } return retriever.GetAlbumInfo(ctx, name, artist, mbid) }) @@ -358,7 +273,7 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) { retriever, ok := ag.(AlbumImageRetriever) if !ok { - return nil, errUnsupported + return nil, ErrNotFound } return retriever.GetAlbumImages(ctx, name, artist, mbid) }) @@ -369,7 +284,7 @@ func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, m return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByTrackRetriever) if !ok { - return nil, errUnsupported + return nil, ErrNotFound } return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count) }) @@ -380,7 +295,7 @@ func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, m return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByAlbumRetriever) if !ok { - return nil, errUnsupported + return nil, ErrNotFound } return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count) }) @@ -398,61 +313,16 @@ func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid str return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByArtistRetriever) if !ok { - return nil, errUnsupported + return nil, ErrNotFound } return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count) }) } -// agentAttempts tallies what the enabled agents did in one dispatch. -type agentAttempts struct { - cooldowns *cooldowns - throttled bool - answered bool -} - -func newAttempts(c *cooldowns) agentAttempts { - return agentAttempts{cooldowns: c} -} - -// skip reports whether name is still cooling down, counting it as throttled for this dispatch. -func (t *agentAttempts) skip(name string) bool { - if !t.cooldowns.active(name) { - return false - } - t.throttled = true - return true -} - -// record files one agent's outcome, parking it when it asked to be retried later. -func (t *agentAttempts) record(name string, err error) { - switch retry, isRetryLater := errors.AsType[*RetryLaterError](err); { - case errors.Is(err, errUnsupported): - case isRetryLater: - t.cooldowns.park(name, cmp.Or(retry.RetryIn, agentCooldown)) - t.throttled = true - default: - t.answered = true - } -} - -// noResultErr tells a retryable empty dispatch (nobody answered) from a definitive miss. -func (t *agentAttempts) noResultErr() error { - if t.throttled && !t.answered { - return ErrRetryLater - } - return ErrNotFound -} - -// callAgent tries each enabled agent in order until found reports a usable result. -func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error), found func(T) bool) (T, error) { +func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) { var zero T start := time.Now() - attempts := newAttempts(&agents.cooldowns) for _, enabledAgent := range agents.getEnabledAgentNames() { - if attempts.skip(enabledAgent.name) { - continue - } ag := agents.getAgent(enabledAgent) if ag == nil { continue @@ -461,29 +331,41 @@ func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn break } result, err := fn(ag) - attempts.record(enabledAgent.name, err) if err != nil { log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) continue } - if found(result) { + if result != zero { log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start)) return result, nil } } - return zero, attempts.noResultErr() -} - -func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) { - return callAgent(ctx, agents, methodName, fn, func(result T) bool { - var zero T - return result != zero - }) + return zero, ErrNotFound } func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) { - return callAgent(ctx, agents, methodName, fn, func(results []T) bool { return len(results) > 0 }) + start := time.Now() + for _, enabledAgent := range agents.getEnabledAgentNames() { + ag := agents.getAgent(enabledAgent) + if ag == nil { + continue + } + if utils.IsCtxDone(ctx) { + break + } + results, err := fn(ag) + if err != nil { + log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) + continue + } + + if len(results) > 0 { + log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start)) + return results, nil + } + } + return nil, ErrNotFound } var _ Interface = (*Agents)(nil) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index 6163c7f3c..50285a084 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -3,8 +3,6 @@ package agents import ( "context" "errors" - "slices" - "time" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -16,29 +14,6 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("cooldowns", func() { - // Calls to one agent overlap, so a short cooldown can land after a long one started. - It("keeps the longer deadline when a shorter park lands after it", func() { - c := cooldowns{until: map[string]time.Time{}} - - c.park("fake", time.Hour) - c.park("fake", time.Millisecond) - - time.Sleep(10 * time.Millisecond) - Expect(c.active("fake")).To(BeTrue()) - }) - - It("extends the deadline when the later park is longer", func() { - c := cooldowns{until: map[string]time.Time{}} - - c.park("fake", time.Millisecond) - c.park("fake", time.Hour) - - time.Sleep(10 * time.Millisecond) - Expect(c.active("fake")).To(BeTrue()) - }) -}) - var _ = Describe("Agents", func() { var ctx context.Context var cancel context.CancelFunc @@ -59,10 +34,10 @@ var _ = Describe("Agents", func() { }) It("calls the placeholder GetArtistImages", func() { - mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One"}, {ID: "2", Title: "Two"}}) + mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}}) songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2) Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(ConsistOf([]Song{{ID: "1", Name: "One"}, {ID: "2", Name: "Two"}})) + Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}})) }) }) @@ -92,22 +67,6 @@ var _ = Describe("Agents", func() { Expect(ags).ToNot(ContainElement("disabled")) }) - Describe("availableAgentNames", func() { - It("combines built-in agents with the given plugins", func() { - names := availableAgentNames([]string{"apple-music"}) - Expect(names).To(ContainElements("apple-music", LocalAgentName, "fake", "empty")) - }) - - It("returns the names sorted", func() { - names := availableAgentNames([]string{"zz-plugin", "aa-plugin"}) - Expect(slices.IsSorted(names)).To(BeTrue()) - }) - - It("works when there are no plugins", func() { - Expect(availableAgentNames(nil)).To(ContainElement(LocalAgentName)) - }) - }) - Describe("GetArtistMBID", func() { It("returns on first match", func() { Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid")) @@ -201,102 +160,6 @@ var _ = Describe("Agents", func() { }) }) - Describe("cooldown", func() { - It("skips an agent that returned RetryLaterError until the deadline", func() { - mock.Err = &RetryLaterError{RetryIn: time.Hour} - _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - - // Immediately after: agent is skipped, not called - mock.Err = nil - calls := mock.Calls - _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(mock.Calls).To(Equal(calls)) - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - }) - - // Providers that throttle without saying for how long (Last.fm sends no delay at all) - // must still be parked, or the aggregate keeps calling them on every request. - It("parks an agent that asked to be retried without a delay", func() { - mock.Err = ErrRetryLater - _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - - mock.Err = nil - calls := mock.Calls - _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(mock.Calls).To(Equal(calls), "the default cooldown must outlast the request") - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - }) - - It("calls the agent again once the cooldown expires", func() { - mock.Err = &RetryLaterError{RetryIn: 10 * time.Millisecond} - _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - - mock.Err = nil - Eventually(func() (string, error) { - return ag.GetArtistBiography(ctx, "id", "name", "mbid") - }, 5*time.Second, 10*time.Millisecond).Should(Equal("bio")) - }) - - It("returns ErrNotFound, not ErrRetryLater, when agents failed for other reasons", func() { - mock.Err = errors.New("boom") - _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) - Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) - }) - - // ErrRetryLater tells the caller "nobody answered, do not cache this". A definitive - // answer from any other agent is an answer, throttled peer or not. - It("returns ErrNotFound when another agent answered with a definitive miss", func() { - other := &mockAgent{Err: ErrNotFound} - Register("fake2", func(model.DataStore) Interface { return other }) - conf.Server.Agents = "fake,fake2" - ag = createAgents(ds, nil) - mock.Err = &RetryLaterError{RetryIn: time.Hour} - - _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) - Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) - - // The cooldown was still recorded for the throttled agent - calls := mock.Calls - _, _ = ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(mock.Calls).To(Equal(calls)) - }) - - It("returns ErrNotFound when another agent answered with an empty slice", func() { - empty := &testImageAgent{Name: "emptyImages"} - Register("emptyImages", func(model.DataStore) Interface { return empty }) - conf.Server.Agents = "fake,emptyImages" - ag = createAgents(ds, nil) - mock.Err = &RetryLaterError{RetryIn: time.Hour} - - _, err := ag.GetArtistImages(ctx, "123", "test", "mb123") - Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) - Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) - }) - - It("returns ErrRetryLater from GetSimilarArtists when only cooling agents remain", func() { - mock.Err = &RetryLaterError{RetryIn: time.Hour} - _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - }) - - It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() { - other := &mockAgent{Err: ErrNotFound} - Register("fake2", func(model.DataStore) Interface { return other }) - conf.Server.Agents = "fake,fake2" - ag = createAgents(ds, nil) - mock.Err = &RetryLaterError{RetryIn: time.Hour} - - _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) - Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) - Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) - }) - }) - Describe("GetArtistImages", func() { It("returns on first match", func() { Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{ @@ -499,70 +362,11 @@ var _ = Describe("Agents", func() { }) }) }) - - Describe("Image retriever enumeration", func() { - var ag *Agents - var artistImg, artistImg2 *testImageAgent - var albumImg, albumImg2 *testAlbumImageAgent - - BeforeEach(func() { - artistImg = &testImageAgent{Name: "artistImg"} - artistImg2 = &testImageAgent{Name: "artistImg2"} - albumImg = &testAlbumImageAgent{name: "albumImg"} - albumImg2 = &testAlbumImageAgent{name: "albumImg2"} - Register("artistImg", func(model.DataStore) Interface { return artistImg }) - Register("artistImg2", func(model.DataStore) Interface { return artistImg2 }) - Register("albumImg", func(model.DataStore) Interface { return albumImg }) - Register("albumImg2", func(model.DataStore) Interface { return albumImg2 }) - Register("noImages", func(model.DataStore) Interface { return &emptyAgent{} }) - }) - - Describe("ArtistImageAgents", func() { - It("returns only ArtistImageRetriever agents, named, in configured order", func() { - conf.Server.Agents = "artistImg,noImages,artistImg2" - ag = createAgents(ds, nil) - - result := ag.ArtistImageAgents() - Expect(result).To(HaveLen(2)) - Expect(result[0].Name).To(Equal("artistImg")) - Expect(result[0].Retriever).To(BeIdenticalTo(artistImg)) - Expect(result[1].Name).To(Equal("artistImg2")) - Expect(result[1].Retriever).To(BeIdenticalTo(artistImg2)) - }) - - It("is empty when external services are disabled", func() { - conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false - ag = createAgents(ds, nil) - Expect(ag.ArtistImageAgents()).To(BeEmpty()) - }) - }) - - Describe("AlbumImageAgents", func() { - It("returns only AlbumImageRetriever agents, named, in configured order", func() { - conf.Server.Agents = "albumImg,noImages,albumImg2" - ag = createAgents(ds, nil) - - result := ag.AlbumImageAgents() - Expect(result).To(HaveLen(2)) - Expect(result[0].Name).To(Equal("albumImg")) - Expect(result[0].Retriever).To(BeIdenticalTo(albumImg)) - Expect(result[1].Name).To(Equal("albumImg2")) - Expect(result[1].Retriever).To(BeIdenticalTo(albumImg2)) - }) - - It("is empty when external services are disabled", func() { - conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false - ag = createAgents(ds, nil) - Expect(ag.AlbumImageAgents()).To(BeEmpty()) - }) - }) - }) }) type mockAgent struct { - Args []any - Err error - Calls int + Args []any + Err error } func (a *mockAgent) AgentName() string { @@ -587,7 +391,6 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) { a.Args = []any{id, name, mbid} - a.Calls++ if a.Err != nil { return "", a.Err } @@ -694,17 +497,3 @@ func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid strin t.Args = []any{id, name, mbid} return t.Images, t.Err } - -type testAlbumImageAgent struct { - name string - Images []ExternalImage - Err error - Args []any -} - -func (t *testAlbumImageAgent) AgentName() string { return t.name } - -func (t *testAlbumImageAgent) GetAlbumImages(_ context.Context, name, artist, mbid string) ([]ExternalImage, error) { - t.Args = []any{name, artist, mbid} - return t.Images, t.Err -} diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 9225a0442..19df91d02 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -3,11 +3,7 @@ package agents import ( "context" "errors" - "fmt" - "strconv" - "time" - "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/model" ) @@ -37,67 +33,20 @@ type ExternalImage struct { } type Song struct { - ID string - Name string - MBID string - ISRC string - Artists []Artist - Album string - AlbumMBID string - Duration uint32 // Duration in milliseconds, 0 means unknown + ID string + Name string + MBID string + ISRC string + Artist string + ArtistMBID string + 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 -} - -// ErrNotFound means the provider answered and had nothing. Return the underlying error -// for a fault instead, or callers that back off on faults will treat it as definitive. -var ErrNotFound = errors.New("not found") - -// ErrRetryLater is the zero-delay RetryLaterError: the provider is temporarily unavailable -// or throttling us, but did not say for how long. Both errors.Is(err, ErrRetryLater) and -// errors.AsType[*RetryLaterError] match it and every delay-carrying variant. -// Treat it as immutable; build a new RetryLaterError to name a delay. -var ErrRetryLater = &RetryLaterError{} - -// RetryLaterError asks callers to back off, optionally for the delay the provider requested. -type RetryLaterError struct { - RetryIn time.Duration -} - -func (e *RetryLaterError) Error() string { - if e.RetryIn > 0 { - return fmt.Sprintf("retry later (in %s)", e.RetryIn) - } - return "retry later" -} - -func (e *RetryLaterError) Is(target error) bool { - _, ok := target.(*RetryLaterError) - return ok -} - -// MaxRetryIn caps a delay parsed from a provider, so a bogus value cannot park it indefinitely. -const MaxRetryIn = time.Hour -const maxRetryInSeconds = int(MaxRetryIn / time.Second) - -// ParseRetryIn reads a provider's delay given in seconds, from a header or a plugin token. -// Anything unparseable or non-positive means unspecified. -func ParseRetryIn(seconds string) time.Duration { - // Clamp in seconds: scaling first would wrap a huge value past int64 nanoseconds, - // turning "wait an age" into a fraction of a second. Parse at a fixed width so the - // cap holds on the 32-bit targets we ship, where a plain Atoi would overflow first. - secs, err := strconv.ParseInt(seconds, 10, 64) - if err != nil || secs <= 0 { - return 0 - } - return time.Duration(min(secs, int64(maxRetryInSeconds))) * time.Second -} +var ( + ErrNotFound = errors.New("not found") +) // AlbumInfoRetriever provides album info (no images) type AlbumInfoRetriever interface { diff --git a/core/agents/interfaces_test.go b/core/agents/interfaces_test.go deleted file mode 100644 index 6acbc545d..000000000 --- a/core/agents/interfaces_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package agents_test - -import ( - "errors" - "fmt" - "time" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/scrobbler" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("RetryLaterError", func() { - It("matches the ErrRetryLater sentinel via errors.Is", func() { - err := &agents.RetryLaterError{RetryIn: 30 * time.Second} - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - }) - - It("matches through errors.Join and wrapping", func() { - err := fmt.Errorf("calling LB: %w", errors.Join(errors.New("http 429"), &agents.RetryLaterError{})) - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - }) - - It("exposes the delay through the wrapped error", func() { - err := errors.Join(errors.New("http 429"), &agents.RetryLaterError{RetryIn: 42 * time.Second}) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(42 * time.Second)) - }) - - It("matches the sentinel too, reporting no delay", func() { - retry, ok := errors.AsType[*agents.RetryLaterError](agents.ErrRetryLater) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(BeZero()) - }) - - It("is the same sentinel as scrobbler.ErrRetryLater", func() { - Expect(errors.Is(scrobbler.ErrRetryLater, agents.ErrRetryLater)).To(BeTrue()) - Expect(errors.Is(&agents.RetryLaterError{}, scrobbler.ErrRetryLater)).To(BeTrue()) - }) -}) diff --git a/core/agents/local_agent.go b/core/agents/local_agent.go index c777ab46d..ce8f9f07c 100644 --- a/core/agents/local_agent.go +++ b/core/agents/local_agent.go @@ -5,8 +5,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/utils/slice" ) const LocalAgentName = "local" @@ -24,7 +22,7 @@ func (p *localAgent) AgentName() string { } func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) { - top, err := p.ds.MediaFile().GetAll(ctx, model.QueryOptions{ + top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Sort: "playCount", Order: "desc", Max: count, @@ -39,51 +37,14 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid if err != nil { return nil, err } - return songsFrom(top), nil -} - -func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) { - seed, err := p.ds.MediaFile().Get(ctx, id) - if err != nil { - return nil, err + var result []Song + for _, s := range top { + result = append(result, Song{ + Name: s.Title, + MBID: s.MbzReleaseTrackID, + }) } - // Tag ids derive from (name, value), so the seed's genre ids need no extra query. - genreIDs := slice.Map(seed.Tags.Flatten(model.TagGenre), func(t model.Tag) string { return t.ID }) - if len(genreIDs) == 0 { - return nil, nil - } - // Ask for extra so we can drop the seed itself and still fill the count. - candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{ - Filters: squirrel.And{ - persistence.SongGenres.ByID(genreIDs), - squirrel.Eq{"missing": false}, - }, - Max: count + 1, - }) - if err != nil { - return nil, err - } - filtered := make(model.MediaFiles, 0, len(candidates)) - for _, s := range candidates { - if s.ID == id { - continue - } - filtered = append(filtered, s) - if len(filtered) >= count { - break - } - } - return songsFrom(filtered), nil -} - -func songsFrom(mfs model.MediaFiles) []Song { - if len(mfs) == 0 { - return nil - } - - return slice.Map(mfs, func(mf model.MediaFile) Song { - return Song{ID: mf.ID, Name: mf.Title} - }) + return result, nil } func init() { diff --git a/core/agents/local_agent_test.go b/core/agents/local_agent_test.go deleted file mode 100644 index 50a0ce297..000000000 --- a/core/agents/local_agent_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package agents - -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("localAgent GetSimilarSongsByTrack", func() { - var ds *tests.MockDataStore - var mfRepo *tests.MockMediaFileRepo - var agent *localAgent - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - mfRepo = &tests.MockMediaFileRepo{} - ds = &tests.MockDataStore{MockedMediaFile: mfRepo} - agent = &localAgent{ds: ds} - }) - - It("excludes the seed track from its own similars", func() { - seed := model.MediaFile{ID: "seed-1", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - related := model.MediaFile{ID: "rel-1", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - // SetData keys by ID; a duplicate "seed-1" entry would clobber the real seed. - mfRepo.SetData(model.MediaFiles{seed, related}) - - songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-1", "Seed", "", "", 10) - - Expect(err).ToNot(HaveOccurred()) - names := slice.Map(songs, func(s Song) string { return s.Name }) - Expect(names).ToNot(ContainElement("Seed")) - }) - - // The mock ignores QueryOptions.Filters, so assert the predicate itself: otherwise this spec - // would pass just as well with no genre filter at all. - It("queries the indexed genre join for the seed's own genres, skipping missing files", func() { - rock := model.NewTag(model.TagGenre, "Rock") - seed := model.MediaFile{ID: "seed-4", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - mfRepo.SetData(model.MediaFiles{seed}) - - _, err := agent.GetSimilarSongsByTrack(ctx, "seed-4", "Seed", "", "", 10) - Expect(err).ToNot(HaveOccurred()) - - sql, args, sqlErr := mfRepo.Options.Filters.ToSql() - Expect(sqlErr).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("media_file_tags"), "must use the indexed join, not a json_tree scan") - Expect(sql).To(ContainSubstring("missing")) - Expect(args).To(ContainElement(false), "must exclude missing files, not select them") - Expect(args).To(ContainElement(rock.ID), "must filter on the seed's own genre tag id") - Expect(args).ToNot(ContainElement(model.NewTag(model.TagGenre, "Jazz").ID)) - }) - - It("returns the library id so the matcher can resolve the song", func() { - seed := model.MediaFile{ID: "seed-3", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - // Without the id the matcher falls through to its MBID/title phases and resolves nothing, - // so the local fallback silently returns an empty mix. - related := model.MediaFile{ID: "rel-3", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - mfRepo.SetData(model.MediaFiles{seed, related}) - - songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-3", "Seed", "", "", 10) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(ContainElement(Song{ID: "rel-3", Name: "Related"})) - }) - - It("asks for one extra candidate so dropping the seed still fills the count", func() { - // The mock returns rows sorted by id, so the seed comes first and would consume the only - // slot if the query did not over-fetch. - seed := model.MediaFile{ID: "a-seed", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - related := model.MediaFile{ID: "b-rel", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - mfRepo.SetData(model.MediaFiles{seed, related}) - - songs, err := agent.GetSimilarSongsByTrack(ctx, "a-seed", "Seed", "", "", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].Name).To(Equal("Related")) - }) - - It("returns nil when the seed track has no genres", func() { - seed := model.MediaFile{ID: "seed-2", Title: "NoGenre"} - mfRepo.SetData(model.MediaFiles{seed}) - - songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-2", "NoGenre", "", "", 10) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - // Without the early return an empty tag filter would scan the whole library. - Expect(mfRepo.Options).To(Equal(model.QueryOptions{}), "must not query at all") - }) -}) diff --git a/core/agents/session_keys.go b/core/agents/session_keys.go index 1eb414b15..cea6005ff 100644 --- a/core/agents/session_keys.go +++ b/core/agents/session_keys.go @@ -13,13 +13,13 @@ type SessionKeys struct { } func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error { - return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey) + return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey) } func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) { - return sk.DataStore.UserProps().Get(ctx, userId, sk.KeyName) + return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName) } func (sk *SessionKeys) Delete(ctx context.Context, userId string) error { - return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName) + return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName) } diff --git a/core/agents/song_test.go b/core/agents/song_test.go deleted file mode 100644 index c13710a38..000000000 --- a/core/agents/song_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package agents - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Song.Equals", func() { - base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} - It("true for identical songs incl Artists", func() { - Expect(base.Equals(base)).To(BeTrue()) - }) - It("false when Artists differ", func() { - other := base - other.Artists = []Artist{{ID: "y", Name: "B"}} - Expect(base.Equals(other)).To(BeFalse()) - }) - It("false when a scalar differs", func() { - other := base - other.Name = "T" - Expect(base.Equals(other)).To(BeFalse()) - }) - It("true when both have empty Artists and equal scalars", func() { - a := Song{ID: "1", Name: "S"} - Expect(a.Equals(a)).To(BeTrue()) - }) -}) diff --git a/core/archiver.go b/core/archiver.go index 60eb44858..96cc2c31e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -2,99 +2,67 @@ package core import ( "archive/zip" - "cmp" "context" - "errors" "fmt" "io" - "net/http" "os" - "path" "path/filepath" - "strconv" "strings" - "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/artwork" "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/persistence" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" ) -const archiveCoverArtSize = 500 - type Archiver interface { ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error - ZipShare(ctx context.Context, s *model.Share, w io.Writer) error + ZipShare(ctx context.Context, id string, w io.Writer) error ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } -func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver { - return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork} +func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver { + return &archiver{ds: ds, ms: ms, shares: shares} } type archiver struct { - ds model.DataStore - ms stream.MediaStreamer - decider stream.TranscodeDecider - shares Share - artwork artwork.Artwork + ds model.DataStore + ms stream.MediaStreamer + shares Share } func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { - return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}, model.ArtworkID{}) + return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}) } func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { - // Match by album-artist participation, not the deprecated album_artist_id - // column (first album artist only), so co-album-artists are included too. - filter := squirrel.And{ - persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist), - squirrel.Eq{"missing": false}, - } - return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID()) + return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id}) } -// rootArt, when set, is added to the archive root. -func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error { - mfs, err := a.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: filters, Sort: "album"}) +func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error { + mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"}) if err != nil { log.Error(ctx, "Error loading mediafiles from artist", "id", id, err) return err } z := createZipWriter(out, format, bitrate) - albums := slice.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID }) - folders := albumFolders(albums) + albums := slice.Group(mfs, func(mf model.MediaFile) string { + return mf.AlbumID + }) for _, album := range albums { discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber }) isMultiDisc := len(discs) > 1 - folder := folders[album[0].AlbumID] - log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder, + log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) for _, mf := range album { - req := a.resolveRequest(ctx, &mf, format, bitrate) - file := a.albumFilename(mf, req.Format, isMultiDisc, folder) - if addErr := a.addFileToZip(ctx, z, mf, req, 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 - } + file := a.albumFilename(mf, format, isMultiDisc) + _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) } - // After the tracks, so a slow artwork lookup doesn't delay the first bytes. - a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder) } - a.addCoverArtToZip(ctx, z, rootArt, "") err = z.Close() if err != nil { log.Error(ctx, "Error closing zip file", "id", id, err) @@ -112,61 +80,7 @@ func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer { return z } -// Tried in order; the first one whose values are distinct across the clashing albums wins. -// One album may have an empty value: it keeps the plain name, which the others can't clash with. -var albumDisambiguators = []func(model.MediaFile) string{ - func(mf model.MediaFile) string { return mf.Tags.First(model.TagAlbumVersion) }, - func(mf model.MediaFile) string { - // Reissues share Year (often the original's) but not ReleaseYear. - if y := cmp.Or(mf.ReleaseYear, mf.Year); y != 0 { - return strconv.Itoa(y) - } - return "" - }, - func(mf model.MediaFile) string { return mf.MbzAlbumType }, - func(mf model.MediaFile) string { return mf.Tags.First(model.TagRecordLabel) }, - func(mf model.MediaFile) string { return mf.CatalogNum }, - func(mf model.MediaFile) string { return mf.AlbumID[:min(6, len(mf.AlbumID))] }, - func(mf model.MediaFile) string { return mf.AlbumID }, -} - -// albumFolders maps each album id to its zip folder. Albums whose names sanitize to the -// same folder get a " [suffix]" from the first disambiguator that tells them all apart. -func albumFolders(albums [][]model.MediaFile) map[string]string { - byName := map[string][]model.MediaFile{} - for _, album := range albums { - name := str.SanitizeFilename(album[0].FullAlbumName()) - byName[name] = append(byName[name], album[0]) - } - folders := make(map[string]string, len(albums)) - for name, group := range byName { - if len(group) == 1 { - folders[group[0].AlbumID] = name - continue - } - fields: - for _, field := range albumDisambiguators { - ids := make(map[string]string, len(group)) // suffix -> album id - for _, mf := range group { - s := str.SanitizeFilename(field(mf)) - if _, dup := ids[s]; dup { - continue fields - } - ids[s] = mf.AlbumID - } - for s, id := range ids { - folders[id] = name - if s != "" { - folders[id] = fmt.Sprintf("%s [%s]", name, s) - } - } - break - } - } - return folders -} - -func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool, folder string) string { +func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string { _, file := filepath.Split(mf.Path) if format != "raw" { file = strings.TrimSuffix(file, mf.Suffix) + format @@ -174,50 +88,42 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc if isMultiDisc { file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file) } - return fmt.Sprintf("%s/%s", folder, file) + return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file) } -// ZipShare takes an already-loaded share: Share.Load records a visit, so -// loading it again here would count every download twice. -func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer) error { +func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error { + s, err := a.shares.Load(ctx, id) + if err != nil { + return err + } if !s.Downloadable { return model.ErrNotAuthorized } log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks)) - // The share is the authorization (as in the public image handler): an anonymous lookup would - // hide a private playlist. Only the cover read is elevated. - coverCtx := request.WithUser(ctx, model.User{IsAdmin: true}) - return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false) + return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false) } func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { - pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false) + pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false) if err != nil { log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err) return err } mfs := pls.MediaFiles() log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs)) - return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, ctx, pls.CoverArtID(), true) + return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true) } -func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, coverCtx context.Context, coverArt model.ArtworkID, addM3U bool) error { +func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error { z := createZipWriter(out, format, bitrate) zippedMfs := make(model.MediaFiles, len(mfs)) for idx, mf := range mfs { - req := a.resolveRequest(ctx, &mf, format, bitrate) - file := a.playlistFilename(mf, req.Format, idx) - if addErr := a.addFileToZip(ctx, z, mf, req, 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 - } + file := a.playlistFilename(mf, format, idx) + _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) mf.Path = file zippedMfs[idx] = mf } - a.addCoverArtToZip(coverCtx, z, coverArt, "") // Add M3U file if requested if addM3U && len(zippedMfs) > 0 { @@ -254,36 +160,8 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext) } -func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request { - if format == "" || format == "raw" { - return stream.Request{Format: "raw"} - } - return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0) -} - -func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error { +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 req.Format != "raw" { - r, err = a.ms.NewStream(ctx, &mf, req) - } else { - r, err = os.Open(path) - } - if err != nil { - log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.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, @@ -294,6 +172,23 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med return err } + var r io.ReadCloser + if format != "raw" && format != "" { + r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) + } else { + r, err = os.Open(path) + } + if err != nil { + log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) + return err + } + + defer func() { + if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { + log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err) + } + }() + _, err = io.Copy(w, r) if err != nil { log.Error(ctx, "Error zipping file", "file", path, err) @@ -302,61 +197,3 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med return nil } - -// addCoverArtToZip adds the cover as dir/folder.. Errors are logged, never returned. -func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) { - if artID.ID == "" { - return - } - // Buffered so a failed read leaves no empty entry. - data, err := a.readCoverArt(ctx, artID) - if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) { - log.Debug(ctx, "No cover art to add to zip", "artID", artID) - return - } - if err != nil { - log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err) - return - } - ext := coverArtExtension(data) - if ext == "" { - log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID) - return - } - w, err := z.CreateHeader(&zip.FileHeader{ - Name: path.Join(dir, "folder."+ext), - Modified: time.Now(), - Method: zip.Store, - }) - if err != nil { - log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err) - return - } - if _, err = w.Write(data); err != nil { - log.Warn(ctx, "Error zipping cover art", "artID", artID, err) - } -} - -func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) { - img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false) - if err != nil { - return nil, err - } - defer img.Close() - return io.ReadAll(img) -} - -// Resizing may re-encode the image, so the type comes from its bytes. -func coverArtExtension(data []byte) string { - switch http.DetectContentType(data) { - case "image/jpeg": - return "jpg" - case "image/png": - return "png" - case "image/webp": - return "webp" - case "image/gif": - return "gif" - } - return "" -} diff --git a/core/archiver_test.go b/core/archiver_test.go index 178d1b6b9..4f7aed278 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -4,19 +4,13 @@ import ( "archive/zip" "bytes" "context" - "errors" "io" "strings" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -26,19 +20,15 @@ var _ = Describe("Archiver", func() { var ( arch core.Archiver ms *mockMediaStreamer - dc *fakeDecider ds *mockDataStore sh *mockShare - ca *mockCoverArt ) BeforeEach(func() { ms = &mockMediaStreamer{} - dc = &fakeDecider{} sh = &mockShare{} ds = &mockDataStore{} - ca = &mockCoverArt{images: map[string][]byte{}} - arch = core.NewArchiver(ms, dc, ds, sh, ca) + arch = core.NewArchiver(ms, ds, sh) }) Context("ZipAlbum", func() { @@ -54,7 +44,7 @@ var _ = Describe("Archiver", func() { Sort: "album", }}).Return(mfs, nil) - ds.On("MediaFile").Return(mfRepo) + ds.On("MediaFile", mock.Anything).Return(mfRepo) ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) out := new(bytes.Buffer) @@ -68,23 +58,6 @@ var _ = Describe("Archiver", func() { Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3")) Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3")) }) - - It("streams the request resolved by the transcode decider and names the entry after its format", func() { - mfRepo := &mockMediaFileRepository{} - mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil) - ds.On("MediaFile").Return(mfRepo) - resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2} - dc.resolved = &resolved - ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once() - - out := new(bytes.Buffer) - Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed()) - ms.AssertExpectations(GinkgoT()) - - zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) - Expect(err).ToNot(HaveOccurred()) - Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus")) - }) }) Context("ZipArtist", func() { @@ -96,14 +69,11 @@ var _ = Describe("Archiver", func() { mfRepo := &mockMediaFileRepository{} mfRepo.On("GetAll", []model.QueryOptions{{ - Filters: squirrel.And{ - persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), - squirrel.Eq{"missing": false}, - }, - Sort: "album", + Filters: squirrel.Eq{"album_artist_id": "1"}, + Sort: "album", }}).Return(mfs, nil) - ds.On("MediaFile").Return(mfRepo) + ds.On("MediaFile", mock.Anything).Return(mfRepo) ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) @@ -117,166 +87,6 @@ var _ = Describe("Archiver", func() { Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3")) Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3")) }) - - When("albums that share a name", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - // zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order. - zipArtistEntries := func(mfs model.MediaFiles) []string { - mfRepo := &mockMediaFileRepository{} - mfRepo.On("GetAll", mock.Anything).Return(mfs, nil) - ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) - - out := new(bytes.Buffer) - Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) - Expect(err).To(BeNil()) - names := make([]string, len(zr.File)) - for i, f := range zr.File { - names[i] = f.Name - } - return names - } - - It("keeps the albums in query order", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Album C"}, - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"}, - {Path: "a/02.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"}, - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album B"}, - }) - Expect(names).To(Equal([]string{"Album C/01.mp3", "Album A/01.mp3", "Album A/02.mp3", "Album B/01.mp3"})) - }) - - It("suffixes the year when it tells the albums apart", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01 - Intro.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, - {Path: "b/01 - Intro.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005}, - }) - Expect(names).To(Equal([]string{"Greatest Hits [2001]/01 - Intro.mp3", "Greatest Hits [2005]/01 - Intro.mp3"})) - }) - - It("prefers the release year, so reissues of the same original are told apart", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2011}, - {Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 1996}, - }) - Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2011]/01.mp3", "Greatest Hits [1996]/01.mp3"})) - }) - - It("names the folder after the full album name", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, - Tags: model.Tags{model.TagAlbumVersion: {"Original"}}}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, - Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}}, - }) - Expect(names).To(Equal([]string{"Greatest Hits (Original)/01.mp3", "Greatest Hits (CD_Digital)/01.mp3"})) - }) - - It("prefers the album version over the year when it is not part of the name", func() { - conf.Server.Subsonic.AppendAlbumVersion = false - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, - Tags: model.Tags{model.TagAlbumVersion: {"Original"}}}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, - Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}}, - }) - Expect(names).To(Equal([]string{"Greatest Hits [Original]/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"})) - }) - - It("leaves the one album without the field unsuffixed", func() { - conf.Server.Subsonic.AppendAlbumVersion = false - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, - Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}}, - }) - Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"})) - }) - - It("skips a field that is empty on more than one album", func() { - conf.Server.Subsonic.AppendAlbumVersion = false - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005}, - {Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 2010, - Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}}, - }) - Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2005]/01.mp3", "Greatest Hits [2010]/01.mp3"})) - }) - - It("skips a field that is the same on every album", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-1"}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-2"}, - }) - Expect(names).To(Equal([]string{"Live [CAT-1]/01.mp3", "Live [CAT-2]/01.mp3"})) - }) - - It("falls back to the album id when nothing differs", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "0123456789abcdef", Album: "Greatest Hits", Year: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "fedcba9876543210", Album: "Greatest Hits", Year: 2001}, - }) - Expect(names).To(Equal([]string{"Greatest Hits [012345]/01.mp3", "Greatest Hits [fedcba]/01.mp3"})) - }) - - It("treats names that sanitize to the same folder as a clash", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "A/B", Year: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: `A\B`, Year: 2005}, - }) - Expect(names).To(Equal([]string{"A_B [2001]/01.mp3", "A_B [2005]/01.mp3"})) - }) - - It("sanitizes the suffix", func() { - conf.Server.Subsonic.AppendAlbumVersion = false - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}}, - }) - Expect(names).To(Equal([]string{"Hits [Vinyl]/01.mp3", "Hits [CD_Digital]/01.mp3"})) - }) - - It("leaves the folder name alone when only one album has it", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Other", Year: 2005}, - }) - Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Other/01.mp3"})) - }) - }) - }) - - 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").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() { @@ -294,16 +104,13 @@ var _ = Describe("Archiver", func() { Tracks: mfs, } + sh.On("Load", mock.Anything, "1").Return(share, nil) ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) - err := arch.ZipShare(context.Background(), share, out) + err := arch.ZipShare(context.Background(), "1", out) Expect(err).To(BeNil()) - // Share.Load records a visit; re-loading here would double-count - // every download. - sh.AssertNotCalled(GinkgoT(), "Load", mock.Anything, mock.Anything) - zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) Expect(err).To(BeNil()) @@ -315,30 +122,6 @@ var _ = Describe("Archiver", func() { }) Context("ZipPlaylist", func() { - It("names the entries and the M3U lines after the resolved format", func() { - pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{ - {MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}}, - }} - plRepo := &mockPlaylistRepository{} - plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) - ds.On("Playlist").Return(plRepo) - dc.resolved = &stream.Request{Format: "opus", BitRate: 128} - ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil) - - out := new(bytes.Buffer) - Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed()) - - zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) - Expect(err).ToNot(HaveOccurred()) - Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus")) - m3u, err := zr.File[1].Open() - Expect(err).ToNot(HaveOccurred()) - defer m3u.Close() - content, err := io.ReadAll(m3u) - Expect(err).ToNot(HaveOccurred()) - Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus")) - }) - It("zips a playlist correctly", func() { tracks := []model.PlaylistTrack{ {MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}}, @@ -353,7 +136,7 @@ var _ = Describe("Archiver", func() { plRepo := &mockPlaylistRepository{} plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) - ds.On("Playlist").Return(plRepo) + ds.On("Playlist", mock.Anything).Return(plRepo) ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) @@ -380,195 +163,24 @@ var _ = Describe("Archiver", func() { Expect(string(m3uContent)).To(Equal(expectedM3U)) }) }) - Context("cover art", func() { - var ( - jpegData = []byte("\xff\xd8\xff\xe0 fake jpeg") - pngData = []byte("\x89PNG\x0d\x0a\x1a\x0a fake png") - ) - - mockAlbumTracks := func(filter squirrel.Sqlizer, mfs model.MediaFiles) { - mfRepo := &mockMediaFileRepository{} - mfRepo.On("GetAll", []model.QueryOptions{{Filters: filter, Sort: "album"}}).Return(mfs, nil) - ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) - } - - It("adds the album cover to the album folder", func() { - ca.images["al-1"] = jpegData - mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{ - {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1}, - }) - - out := new(bytes.Buffer) - Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveLen(2)) - Expect(files).To(HaveKeyWithValue("Album_Promo/folder.jpg", jpegData)) - Expect(ca.requests).To(ConsistOf(coverRequest{id: "al-1", size: 500, square: false})) - }) - - It("adds the artist image to the root and each album cover to its folder", func() { - ca.images["ar-1"] = pngData - ca.images["al-1"] = jpegData - ca.images["al-2"] = jpegData - mockAlbumTracks(squirrel.And{ - persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), - squirrel.Eq{"missing": false}, - }, model.MediaFiles{ - {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1}, - {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album 2", DiscNumber: 1}, - }) - - out := new(bytes.Buffer) - Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveLen(5)) - Expect(files).To(HaveKeyWithValue("folder.png", pngData)) - Expect(files).To(HaveKeyWithValue("Album 1/folder.jpg", jpegData)) - Expect(files).To(HaveKeyWithValue("Album 2/folder.jpg", jpegData)) - }) - - It("puts each same-named album's cover in that album's own folder", func() { - ca.images["al-1"] = jpegData - ca.images["al-2"] = pngData - mockAlbumTracks(squirrel.And{ - persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), - squirrel.Eq{"missing": false}, - }, model.MediaFiles{ - {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, DiscNumber: 1}, - {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, DiscNumber: 1}, - }) - - out := new(bytes.Buffer) - Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveKeyWithValue("Greatest Hits [2001]/folder.jpg", jpegData)) - Expect(files).To(HaveKeyWithValue("Greatest Hits [2005]/folder.png", pngData)) - }) - - It("adds the playlist cover to the root", func() { - ca.images["pl-1"] = jpegData - plRepo := &mockPlaylistRepository{} - plRepo.On("GetWithTracks", "1", true, false).Return(&model.Playlist{ - ID: "1", - Name: "Test Playlist", - Tracks: []model.PlaylistTrack{ - {MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Artist: "Artist 1", Title: "track1"}}, - }, - }, nil) - ds.On("Playlist", mock.Anything).Return(plRepo) - ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) - - out := new(bytes.Buffer) - Expect(arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveLen(3)) - Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData)) - Expect(files).To(HaveKey("Test Playlist.m3u")) - }) - - It("adds the shared item's cover to the root, even for a private playlist", func() { - ca.images["pl-10"] = jpegData - ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) - share := &model.Share{ - ID: "1", - Downloadable: true, - Format: "mp3", - MaxBitRate: 128, - ResourceType: "playlist", - ResourceIDs: "10", - Tracks: model.MediaFiles{ - {ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"}, - }, - } - - out := new(bytes.Buffer) - Expect(arch.ZipShare(context.Background(), share, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveLen(2)) - Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData)) - Expect(ca.requests).To(ConsistOf(coverRequest{id: "pl-10", size: 500, square: false, admin: true})) - }) - - It("still builds the archive when the cover cannot be read", func() { - ca.err = errors.New("boom") - mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{ - {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, - }) - - out := new(bytes.Buffer) - Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveLen(1)) - Expect(files).To(HaveKey("Album/01 - track1.mp3")) - }) - }) }) -func readZip(out *bytes.Buffer) map[string][]byte { - zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) - Expect(err).ToNot(HaveOccurred()) - files := make(map[string][]byte, len(zr.File)) - for _, f := range zr.File { - r, err := f.Open() - Expect(err).ToNot(HaveOccurred()) - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - _ = r.Close() - files[f.Name] = data - } - return files -} - -type coverRequest struct { - id string - size int - square bool - admin bool -} - -type mockCoverArt struct { - artwork.Artwork - images map[string][]byte - err error - requests []coverRequest -} - -func (m *mockCoverArt) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*artwork.Image, error) { - user, _ := request.UserFrom(ctx) - m.requests = append(m.requests, coverRequest{id: artID.String(), size: size, square: square, admin: user.IsAdmin}) - if m.err != nil { - return nil, m.err - } - data, ok := m.images[artID.String()] - if !ok { - return nil, artwork.ErrUnavailable - } - return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(data))}, nil -} - type mockDataStore struct { mock.Mock model.DataStore } -func (m *mockDataStore) MediaFile() model.MediaFileRepository { - args := m.Called() +func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + args := m.Called(ctx) return args.Get(0).(model.MediaFileRepository) } -func (m *mockDataStore) Playlist() model.PlaylistRepository { - args := m.Called() +func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository { + args := m.Called(ctx) return args.Get(0).(model.PlaylistRepository) } -func (m *mockDataStore) Library() model.LibraryRepository { +func (m *mockDataStore) Library(context.Context) model.LibraryRepository { return &mockLibraryRepository{} } @@ -577,7 +189,7 @@ type mockLibraryRepository struct { model.LibraryRepository } -func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) { +func (m *mockLibraryRepository) GetPath(id int) (string, error) { return "/music", nil } @@ -586,7 +198,7 @@ type mockMediaFileRepository struct { model.MediaFileRepository } -func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { args := m.Called(options) return args.Get(0).(model.MediaFiles), args.Error(1) } @@ -596,7 +208,7 @@ type mockPlaylistRepository struct { model.PlaylistRepository } -func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) { +func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) { args := m.Called(id, refreshSmartPlaylists, includeMissing) return args.Get(0).(*model.Playlist), args.Error(1) } @@ -614,19 +226,6 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil } -// fakeDecider echoes the legacy format/bitrate unless a resolved request is set. -type fakeDecider struct { - stream.TranscodeDecider - resolved *stream.Request -} - -func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request { - if f.resolved != nil { - return *f.resolved - } - return stream.Request{Format: format, BitRate: bitRate, Offset: offset} -} - type mockShare struct { mock.Mock core.Share diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go deleted file mode 100644 index 985abacd7..000000000 --- a/core/artwork/agent_images.go +++ /dev/null @@ -1,131 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io" - "net/url" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/str" -) - -// externalName mirrors the normalization the aggregate provider applies, so agent searches match. -func externalName(name string) string { - if conf.Server.DevPreserveUnicodeInExternalCalls { - return name - } - return str.Clear(name) -} - -// bestImageURL returns the largest fetchable image URL. Only one is returned and its failure ends -// the agent's turn, so an unfetchable candidate must never win: url.Parse alone accepts anything. -func bestImageURL(imgs []agents.ExternalImage) *url.URL { - var best *url.URL - var bestSize int - for i := range imgs { - if imgs[i].URL == "" { - continue - } - u, err := url.Parse(imgs[i].URL) - if err != nil || !u.IsAbs() || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - continue - } - if best == nil || imgs[i].Size > bestSize { - best, bestSize = u, imgs[i].Size - } - } - return best -} - -// longerRetry keeps whichever external failure asks for the longer wait, so one provider's -// short delay cannot shorten another's. -func longerRetry(a, b error) error { - if a == nil { - return b - } - var ra, rb *agents.RetryLaterError - if errors.As(b, &rb) && (!errors.As(a, &ra) || rb.RetryIn > ra.RetryIn) { - return b - } - return a -} - -// fetchArtistImage tries each enabled artist-image agent in order. The error is non-nil only when no -// agent succeeded and at least one failed transiently. -func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (io.ReadCloser, string, error) { - // Synthetic artists would otherwise get an unrelated agent result assigned to them. - switch ar.ID { - case consts.UnknownArtistID, consts.VariousArtistsID: - traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"}) - return nil, "", nil - } - name := externalName(ar.Name) - imageAgents := ag.ArtistImageAgents() - if len(imageAgents) == 0 { - traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, - Detail: "no enabled agent provides artist images"}) - return nil, "", nil - } - var extErr error - for _, a := range imageAgents { - reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { - imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID) - if err != nil { - return nil, "", err - } - u := bestImageURL(imgs) - if u == nil { - return nil, "", agents.ErrNotFound - } - return fromURL(ctx, u) - }) - recordAgent(ctx, a.Name, reader, path, err) - if reader != nil { - return reader, a.Name, nil - } - if isTransientExternal(err) { - extErr = longerRetry(extErr, err) - log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err) - } - } - return nil, "", extErr -} - -// fetchAlbumImage is the album counterpart of fetchArtistImage. -func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (io.ReadCloser, string, error) { - name, artist := externalName(al.Name), externalName(al.AlbumArtist) - imageAgents := ag.AlbumImageAgents() - if len(imageAgents) == 0 { - traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, - Detail: "no enabled agent provides album images"}) - return nil, "", nil - } - var extErr error - for _, a := range imageAgents { - reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { - imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID) - if err != nil { - return nil, "", err - } - u := bestImageURL(imgs) - if u == nil { - return nil, "", agents.ErrNotFound - } - return fromURL(ctx, u) - }) - recordAgent(ctx, a.Name, reader, path, err) - if reader != nil { - return reader, a.Name, nil - } - if isTransientExternal(err) { - extErr = longerRetry(extErr, err) - log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err) - } - } - return nil, "", extErr -} diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go deleted file mode 100644 index d0c2429b0..000000000 --- a/core/artwork/agent_images_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/str" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// fakeImageAgent is a built-in agent stub implementing both image retrievers; it -// records call counts so per-agent ordering and short-circuiting can be asserted. -type fakeImageAgent struct { - name string - imgs []agents.ExternalImage - err error - artistCalls int - albumCalls int - gotArtistName string - gotAlbumName string - // block, when set, holds every lookup until closed, standing in for a slow/rate-limited agent. - block chan struct{} - // mu guards the call counters: the worker resolves several items concurrently. - mu sync.Mutex -} - -func (f *fakeImageAgent) AgentName() string { return f.name } - -func (f *fakeImageAgent) GetArtistImages(_ context.Context, _, name, _ string) ([]agents.ExternalImage, error) { - if f.block != nil { - <-f.block - } - f.mu.Lock() - f.artistCalls++ - f.gotArtistName = name - f.mu.Unlock() - return f.imgs, f.err -} - -func (f *fakeImageAgent) GetAlbumImages(_ context.Context, name, _, _ string) ([]agents.ExternalImage, error) { - f.albumCalls++ - f.gotAlbumName = name - return f.imgs, f.err -} - -// imageAgents registers the fakes as built-in agents and enables them in order. The fakes -// ignore the DataStore, so reusing the process-wide GetAgents singleton across tests is safe. -func imageAgents(fakes ...*fakeImageAgent) *agents.Agents { - names := make([]string, 0, len(fakes)) - for _, f := range fakes { - fake := f - agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake }) - names = append(names, fake.name) - } - conf.Server.Agents = strings.Join(names, ",") - return agents.GetAgents(&tests.MockDataStore{}, nil) -} - -var _ = Describe("agent images", func() { - var ( - ctx context.Context - srv *httptest.Server - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("image-bytes")) - })) - DeferCleanup(srv.Close) - }) - - img := func(path string, size int) agents.ExternalImage { - return agents.ExternalImage{URL: srv.URL + path, Size: size} - } - - Describe("bestImageURL", func() { - It("picks the largest-Size URL and skips empty ones", func() { - u := bestImageURL([]agents.ExternalImage{ - {URL: "http://x/small", Size: 10}, - {URL: "", Size: 9999}, - {URL: "http://x/big", Size: 100}, - }) - Expect(u).ToNot(BeNil()) - Expect(u.String()).To(Equal("http://x/big")) - }) - - It("skips a malformed largest URL and falls back to a valid smaller one", func() { - u := bestImageURL([]agents.ExternalImage{ - {URL: "http://x/valid", Size: 10}, - {URL: "http://x/%zz", Size: 100}, // invalid percent-escape, largest - }) - Expect(u).ToNot(BeNil()) - Expect(u.String()).To(Equal("http://x/valid")) - }) - - It("returns nil when there is no non-empty URL", func() { - Expect(bestImageURL(nil)).To(BeNil()) - Expect(bestImageURL([]agents.ExternalImage{{URL: "", Size: 5}})).To(BeNil()) - }) - - // Plugins hand these over as free-form strings, and url.Parse accepts them all. An - // unfetchable candidate that wins here ends the agent's turn before its valid images run. - DescribeTable("skips a candidate that cannot be fetched", - func(badURL string) { - u := bestImageURL([]agents.ExternalImage{ - {URL: badURL, Size: 100}, // largest, and first - {URL: "https://cdn.example.com/ok.jpg", Size: 10}, - }) - Expect(u).ToNot(BeNil()) - Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg")) - }, - Entry("a relative path", "images/big.jpg"), - Entry("a root-relative path", "/images/big.jpg"), - Entry("a scheme we cannot fetch", "ftp://host/big.jpg"), - Entry("a scheme-relative URL", "//host/big.jpg"), - Entry("a URL with no host", "http:///big.jpg"), - ) - - // Size is often 0 for every candidate, and only a strictly larger one replaces the first, - // so an unfetchable entry in first position would otherwise stick. - It("skips an unfetchable first candidate when every Size is zero", func() { - u := bestImageURL([]agents.ExternalImage{ - {URL: "images/rel.jpg"}, - {URL: "https://cdn.example.com/ok.jpg"}, - }) - Expect(u).ToNot(BeNil()) - Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg")) - }) - - It("returns nil when no candidate is fetchable", func() { - Expect(bestImageURL([]agents.ExternalImage{ - {URL: "images/a.jpg", Size: 10}, - {URL: "ftp://host/b.jpg", Size: 20}, - })).To(BeNil()) - }) - }) - - Describe("fetchArtistImage", func() { - It("returns the first agent's image and its name", func() { - a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} - ag := imageAgents(a) - - r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"}) - Expect(r).ToNot(BeNil()) - defer r.Close() - Expect(name).To(Equal("agentA")) - Expect(err).ToNot(HaveOccurred()) - }) - - It("skips the external lookup for synthetic artists", func() { - a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} - ag := imageAgents(a) - - for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} { - r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) - Expect(r).To(BeNil()) - Expect(name).To(BeEmpty()) - Expect(err).ToNot(HaveOccurred()) - } - Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents") - }) - - It("records a skipped external candidate when no agent provides artist images", func() { - ag := imageAgents() - t := &ChainTrace{} - - r, _, err := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(r).To(BeNil()) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, - Detail: "no enabled agent provides artist images"}}), - "a configured external token must never be silently absent from the chain") - }) - - It("records a skipped external candidate for synthetic artists", func() { - a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} - ag := imageAgents(a) - t := &ChainTrace{} - - _, _, _ = fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, - model.Artist{ID: consts.VariousArtistsID, Name: "Various Artists"}) - Expect(t.Steps()).To(HaveLen(1)) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeSkipped)) - Expect(t.Steps()[0].Detail).To(ContainSubstring("synthetic")) - }) - - It("clears typographic characters from the query name unless preserving unicode", func() { - conf.Server.DevPreserveUnicodeInExternalCalls = false - a := &fakeImageAgent{name: "agentA"} - ag := imageAgents(a) - - _, _, _ = fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "AC’DC"}) - Expect(a.gotArtistName).To(Equal(str.Clear("AC’DC"))) - }) - - It("falls through to a later agent, and its success beats the earlier error", func() { - a := &fakeImageAgent{name: "agentA", err: errBreakerOpen} - b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}} - ag := imageAgents(a, b) - - r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(r).ToNot(BeNil()) - defer r.Close() - Expect(name).To(Equal("agentB")) - Expect(err).ToNot(HaveOccurred(), "a later hit clears an earlier agent's error") - Expect(a.artistCalls).To(Equal(1)) - Expect(b.artistCalls).To(Equal(1)) - }) - - It("reports a clean miss when every agent finds nothing", func() { - a := &fakeImageAgent{name: "agentA"} // no images, no error -> not found - b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound} - ag := imageAgents(a, b) - - r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(r).To(BeNil()) - Expect(name).To(BeEmpty()) - Expect(err).ToNot(HaveOccurred(), "not-found is definitive, never a transient failure") - }) - - It("reports an error when one agent fails transiently and the rest find nothing", func() { - a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound} - b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded} - ag := imageAgents(a, b) - - r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(r).To(BeNil()) - Expect(err).To(HaveOccurred()) - }) - - // The worker reschedules on this delay, so it is only honored if the agent loop - // returns it. Two throttled agents: the longest wait is the one that must survive. - It("returns the longest retry delay the providers asked for", func() { - a := &fakeImageAgent{name: "agentA", err: &agents.RetryLaterError{RetryIn: 10 * time.Second}} - b := &fakeImageAgent{name: "agentB", err: &agents.RetryLaterError{RetryIn: 5 * time.Second}} - ag := imageAgents(a, b) - - r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(r).To(BeNil()) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(10 * time.Second)) - }) - - It("returns no delay when the provider did not ask for one", func() { - ag := imageAgents(&fakeImageAgent{name: "agentA", err: errors.New("boom")}) - - _, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(err).To(HaveOccurred()) - _, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeFalse(), "a plain failure must not look like a throttle") - }) - }) - - Describe("fetchAlbumImage", func() { - It("returns the winning agent's image and name", func() { - a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} - ag := imageAgents(a) - - r, name, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"}) - Expect(r).ToNot(BeNil()) - defer r.Close() - Expect(name).To(Equal("agentA")) - Expect(err).ToNot(HaveOccurred()) - Expect(a.albumCalls).To(Equal(1)) - }) - - It("records a skipped external candidate when no agent provides album images", func() { - ag := imageAgents() - t := &ChainTrace{} - - r, _, err := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"}) - Expect(r).To(BeNil()) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, - Detail: "no enabled agent provides album images"}}), - "a configured external token must never be silently absent from the chain") - }) - - It("reports an error when the only agent fails transiently", func() { - a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} - ag := imageAgents(a) - - r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) - Expect(r).To(BeNil()) - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("gate naming", func() { - It("invokes the gate once per agent, keyed by agent name", func() { - a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} - b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 1)}} - ag := imageAgents(a, b) - - var gatedNames []string - gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - gatedNames = append(gatedNames, name) - return f() - } - - r, _, _ := fetchArtistImage(ctx, ag, gate, model.Artist{ID: "ar1"}) - Expect(r).ToNot(BeNil()) - defer r.Close() - Expect(gatedNames).To(Equal([]string{"agentA", "agentB"})) - }) - }) -}) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 2a9ebe771..b8c395c12 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -1,439 +1,134 @@ package artwork import ( - "bytes" "context" "errors" - "fmt" + _ "image/gif" "io" - "os" "time" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/utils/cache" + _ "golang.org/x/image/webp" ) var ErrUnavailable = errors.New("artwork unavailable") -// errStaleSource means the backing file's mtime no longer matches RefMtime, so the stored hash may be stale. -var errStaleSource = errors.New("artwork: source file changed since resolution") - -// Image is one servable artwork response. -type Image struct { - io.ReadCloser - Hash string // pixel identity; "" for placeholders - ETag string // representation validator; "" means Hash applies (full-size original) - LastUpdated time.Time - Placeholder bool -} - -// representationTag varies with dimensions and encode settings, so a config change invalidates -// a revalidating client's cache even though the pixel hash is unchanged. -func representationTag(hash string, size int, square bool) string { - return fmt.Sprintf("%s.%d.%v.%s", hash, size, square, formatQualityTag()) -} - type Artwork interface { - // Get returns ErrUnavailable when there is nothing to serve and model.ErrNotFound when - // the id resolves to nothing, so the caller can pick placeholder vs 404. - Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) - // GetOrPlaceholder accepts an artwork token or a raw entity id, falling back to the - // kind's placeholder image (never resized, Placeholder=true). - GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) + Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) + GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) } -func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Artwork { - return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm} +func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork { + return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider} } -// entityExists reports whether the entity an artwork id points at is still there: state rows -// outlive a deleted entity until the next prune, so a servable row is not evidence of its owner. -func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID) bool { - var found bool - var err error - switch artID.Kind { - case model.KindArtistArtwork: - found, err = ds.Artist().Exists(ctx, artID.ID) - case model.KindAlbumArtwork: - found, err = ds.Album().Exists(ctx, artID.ID) - case model.KindMediaFileArtwork: - found, err = ds.MediaFile().Exists(ctx, artID.ID) - case model.KindPlaylistArtwork: - found, err = ds.Playlist().Exists(ctx, artID.ID) - case model.KindRadioArtwork: - found, err = ds.Radio().Exists(ctx, artID.ID) - case model.KindDiscArtwork: - albumID, _, perr := model.ParseDiscArtworkID(artID.ID) - if perr != nil { - return false - } - found, err = ds.Album().Exists(ctx, albumID) - default: - return false - } - return err == nil && found +type artwork struct { + ds model.DataStore + cache cache.FileCache + ffmpeg ffmpeg.FFmpeg + provider external.Provider } -type service struct { - ds model.DataStore - cache cache.FileCache - store *ImageStore - ffmpeg ffmpeg.FFmpeg +type artworkReader interface { + cache.Item + LastUpdated() time.Time + Reader(ctx context.Context) (io.ReadCloser, string, error) } -func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) { - artID, err := s.parseArtworkID(ctx, id) - var img *Image +func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) { + artID, err := a.getArtworkId(ctx, id) if err == nil { - img, err = s.Get(ctx, artID, size, square) + reader, lastUpdate, err = a.Get(ctx, artID, size, square) } - // Only a resolvable entity with no art gets a placeholder; an unknown id must stay - // ErrNotFound so callers can still answer 404 / Subsonic error 70. if errors.Is(err, ErrUnavailable) { - return placeholderImage(artID.Kind), nil + if artID.Kind == model.KindArtistArtwork { + reader, _ = resources.FS().Open(consts.PlaceholderArtistArt) + } else { + reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt) + } + return reader, consts.ServerStart, nil } - return img, err + return reader, lastUpdate, err } -func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - if artID.ID == "" { - return nil, ErrUnavailable - } - if size < 0 { - size = 0 // a negative size means full-size, not a giant (OOM) resize rectangle - } - switch artID.Kind { - case model.KindDiscArtwork: - return s.serveDisc(ctx, artID, size, square) - case model.KindMediaFileArtwork: - return s.serveMediaFile(ctx, artID, size, square) - default: - return s.serveEntity(ctx, artID, size, square) - } -} - -func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - ia, err := s.ds.Artwork().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary) - switch { - case errors.Is(err, model.ErrNotFound): - return s.provisional(ctx, artID, size, square) - case err != nil: - return nil, err - case ia.Hash == "": - // Settled absent: only an explicit reprocess or refresh retries it. - return nil, ErrUnavailable - default: - return s.serveHash(ctx, artID, ia, size, square) - } -} - -// serveSource is the one place bytes become an Image. hash is the pixel identity ("" for disc art) -// and doubles as the full-size validator, so an ETag is only needed when resized or hash is "". -func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate time.Time, - size int, square bool, open func() (io.ReadCloser, error), -) (*Image, error) { - if size == 0 && !square { - rc, err := open() - if err != nil { - return nil, err - } - if rc == nil { - return nil, ErrUnavailable - } - img := &Image{ReadCloser: rc, Hash: hash, LastUpdated: lastUpdate} - if hash == "" { - img.ETag = representationTag(key, size, square) - } - return img, nil - } - stream, err := s.cache.Get(ctx, &resizedItem{ - hash: key, size: size, square: square, ffmpeg: s.ffmpeg, open: open, - }) +func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) { + artReader, err := a.getArtworkReader(ctx, artID, size, square) if err != nil { - return nil, err + return nil, time.Time{}, err } - return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(key, size, square), LastUpdated: lastUpdate}, nil -} -// serveHash serves the bytes of a found state row. A mismatch/open error is dangling, but a -// cancelled request is not: it must not enqueue a re-resolution. -func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) { - // Only this path can hand back a deleted entity's bytes; the others load their entity anyway. - if !entityExists(ctx, s.ds, artID) { - return nil, ErrUnavailable - } - // Checked here, not in openOriginal: a resize-cache hit never opens the source. - if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) { - log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath) - return s.dangling(ctx, artID) - } - art, err := s.ds.Artwork().GetImage(ctx, ia.Hash) + r, err := a.cache.Get(ctx, artReader) if err != nil { - if errors.Is(err, model.ErrNotFound) { - return s.dangling(ctx, artID) + if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) { + log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err) } - return nil, err + return nil, time.Time{}, err } - img, err := s.serveSource(ctx, ia.Hash, ia.Hash, ia.UpdatedAt, size, square, - func() (io.ReadCloser, error) { return openOriginal(ia, art.Mime, s.store) }) - if err != nil { - if errors.Is(err, context.Canceled) { - return nil, err - } - log.Warn(ctx, "Artwork: Could not serve image", "artID", artID, "size", size, err) - return s.dangling(ctx, artID) - } - return img, nil + return r, artReader.LastUpdated(), nil } -// openOriginal enforces the mtime invariant: bytes are never served under a hash they no longer match. -func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.ReadCloser, error) { - if isFileBacked(ia.Source) { - f, err := os.Open(ia.SourcePath) - if err != nil { - return nil, err - } - info, err := f.Stat() - if err != nil { - f.Close() - return nil, err - } - if ia.RefMtime != 0 && info.ModTime().UnixNano() != ia.RefMtime { - f.Close() - log.Debug("Artwork: Backing file changed since resolution", "path", ia.SourcePath, - "hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano()) - return nil, errStaleSource - } - return f, nil - } - // Store-backed bytes still carry the source's mtime, to detect edits to embedded art. - if ia.SourcePath != "" && ia.RefMtime != 0 { - info, err := os.Stat(ia.SourcePath) - if err != nil { - return nil, err - } - if info.ModTime().UnixNano() != ia.RefMtime { - log.Debug("Artwork: Source file changed since resolution", "path", ia.SourcePath, - "hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano()) - return nil, errStaleSource - } - } - return store.Open(ia.Hash, mime) -} - -// provisional serves local bytes for an entity with no state row, enqueuing the worker but -// never writing a state row itself. -func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - item := model.ArtworkQueueItem{ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary} - res, err := newLocalResolver(s.ds, s.ffmpeg).resolve(ctx, item) - if err != nil { - return nil, err - } - s.enqueue(ctx, artID, model.ArtworkPriorityBump) - log.Debug(ctx, "Artwork: Provisional read-through, no state row yet", "artID", artID, - "source", res.source, "hit", res.reader != nil) - return s.serveResolution(ctx, res, size, square) -} - -// serveResolution turns a local resolution's bytes into a servable Image (byte-hash only, no decode). -func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) { - if res.reader == nil { - return nil, ErrUnavailable - } - defer res.reader.Close() - data, err := readCapped(res.reader) - if err != nil { - return nil, ErrUnavailable - } - hash, err := hashImage(bytes.NewReader(data)) - if err != nil { - return nil, ErrUnavailable - } - // Keyed by the byte-hash, so the entry lines up with the worker's eventual store entry. - return s.serveSource(ctx, hash, hash, unixMtime(res.refMtime), size, square, - func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil }) -} - -func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - // The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL - // must fall back to disc/album instead of serving stale persisted embedded art. - if !conf.Server.EnableMediaFileCoverArt { - mf, err := s.ds.MediaFile().Get(ctx, artID.ID) - if err != nil { - return nil, err - } - return s.Get(ctx, mf.DiscCoverArtID(), size, square) - } - ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary) - switch { - case err == nil && ia.Hash != "": - return s.serveHash(ctx, artID, ia, size, square) - case err == nil: - // absent row: fall through - case errors.Is(err, model.ErrNotFound): - // no row: fall through - default: - return nil, err - } - noRow := errors.Is(err, model.ErrNotFound) - - mf, err := s.ds.MediaFile().Get(ctx, artID.ID) - if err != nil { - return nil, err - } - if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt { - return s.provisionalEmbedded(ctx, artID, *mf, size, square) - } - // Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album. - return s.Get(ctx, mf.DiscCoverArtID(), size, square) -} - -// provisionalEmbedded serves a track's embedded art immediately, leaving the state row to the worker. -func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID, mf model.MediaFile, size int, square bool) (*Image, error) { - lib, err := loadLibraryView(ctx, s.ds, mf.LibraryID) - if err != nil { - return nil, err - } - res, ok := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path) - s.enqueue(ctx, artID, model.ArtworkPriorityBump) - if !ok { - // Eligible but unextractable: fall back the way CoverArtID does, not to a placeholder. - return s.Get(ctx, mf.DiscCoverArtID(), size, square) - } - return s.serveResolution(ctx, res, size, square) -} - -// serveDisc reads disc art through with no state row and no enqueue, falling back to the album cover. -func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - dr, err := newDiscArtworkReader(ctx, s.ds, artID) - if err != nil { - return nil, err - } - // Single-disc albums run the chain too: a disc can carry art distinct from the album cover. - selectImage := func() (io.ReadCloser, error) { - res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{}) - return res.reader, err - } - albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID} - // Disc art has no state row, hence no content hash: keying on id, album mtime and - // DiscArtPriority lets a warm cache answer without running the chain or touching the disk. - key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority) - img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage) - if err != nil { - if errors.Is(err, context.Canceled) { - return nil, err - } - return s.Get(ctx, albumArtID, size, square) - } - return img, nil -} - -// dangling enqueues a re-resolution and reports unavailable, leaving the state row untouched. -func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) { - log.Debug(ctx, "Artwork: State row points at bytes we cannot serve, re-resolving", "artID", artID) - s.enqueue(ctx, artID, model.ArtworkPriorityScan) - return nil, ErrUnavailable -} - -func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) { - err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{ - ItemKind: artID.Kind.Prefix(), - ItemID: artID.ID, - ImageType: model.ImageTypePrimary, - Priority: priority, - }) - if err != nil { - log.Warn(ctx, "Artwork: Could not enqueue re-resolution", "artID", artID, err) - } -} - -func placeholderImage(kind model.Kind) *Image { - path := consts.PlaceholderAlbumArt - if kind == model.KindArtistArtwork { - path = consts.PlaceholderArtistArt - } - r, _ := resources.FS().Open(path) - return &Image{ReadCloser: r, Placeholder: true} -} - -type coverArtIDGetter interface { +type coverArtGetter interface { CoverArtID() model.ArtworkID } -// parseArtworkID accepts an artwork token or a raw entity id, resolving the latter to its CoverArtID. -func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) { +func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) { if id == "" { return model.ArtworkID{}, ErrUnavailable } - if artID, err := model.ParseArtworkID(id); err == nil { + artID, err := model.ParseArtworkID(id) + if err == nil { return artID, nil } - entity, err := model.GetEntityByID(ctx, s.ds, id) + + log.Trace(ctx, "ArtworkID invalid. Trying to figure out kind based on the ID", "id", id) + entity, err := model.GetEntityByID(ctx, a.ds, id) if err != nil { return model.ArtworkID{}, err } - if e, ok := entity.(coverArtIDGetter); ok { - return e.CoverArtID(), nil + if e, ok := entity.(coverArtGetter); ok { + artID = e.CoverArtID() } - return model.ArtworkID{}, model.ErrNotFound + switch e := entity.(type) { + case *model.Artist: + log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name) + case *model.Album: + log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist) + case *model.MediaFile: + log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album) + case *model.Playlist: + log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name) + } + return artID, nil } -// TracingResolver is the CLI's read-only view of resolution: it walks the priority chain, records -// the walk and reports the winning source, without ever writing artwork state. -type TracingResolver struct { - inner *resolver - trace *ChainTrace -} - -// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live -// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider; -// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed. -func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver { - inner := newLocalResolver(ds, ffm) - if live { - inner = newResolver(ds, ag, ffm, passthroughGate) +func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) { + var artReader artworkReader + var err error + if size > 0 || square { + artReader, err = resizedFromOriginal(ctx, a, artID, size, square) + } else { + switch artID.Kind { + case model.KindArtistArtwork: + artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider) + case model.KindAlbumArtwork: + artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider) + case model.KindMediaFileArtwork: + artReader, err = newMediafileArtworkReader(ctx, a, artID) + case model.KindPlaylistArtwork: + artReader, err = newPlaylistArtworkReader(ctx, a, artID) + case model.KindDiscArtwork: + artReader, err = newDiscArtworkReader(ctx, a, artID) + case model.KindRadioArtwork: + artReader, err = newRadioArtworkReader(ctx, a, artID) + default: + return nil, ErrUnavailable + } } - return &TracingResolver{inner: inner, trace: t} -} - -// Resolve walks kind's sources for id, recording the walk, and reports the winning source -// ("" when none produced an image). -func (r *TracingResolver) Resolve(ctx context.Context, kind model.Kind, id string) (string, error) { - switch kind { - case model.KindArtistArtwork: - return r.explain(ctx, r.inner.resolveArtist, id) - case model.KindAlbumArtwork: - return r.explain(ctx, r.inner.resolveAlbum, id) - case model.KindDiscArtwork: - return r.explain(ctx, r.inner.resolveDisc, id) - case model.KindMediaFileArtwork: - return r.explain(ctx, r.inner.resolveMediaFile, id) - } - return "", fmt.Errorf("artwork: %s artwork has no chain to explain", kind) -} - -// explain discards the bytes: nothing downstream persists this resolution, so nothing else -// would close the reader either. -func (r *TracingResolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) { - res, err := resolve(withTrace(ctx, r.trace), id) - if err != nil { - return "", err - } - if res.reader != nil { - _ = res.reader.Close() - } - return res.source, nil -} - -func unixMtime(mtime int64) time.Time { - if mtime <= 0 { - return time.Time{} - } - return time.Unix(0, mtime) // RefMtime is unix-nanoseconds + return artReader, err } diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go new file mode 100644 index 000000000..c95371959 --- /dev/null +++ b/core/artwork/artwork_internal_test.go @@ -0,0 +1,628 @@ +package artwork + +import ( + "context" + "errors" + "image" + "image/jpeg" + "image/png" + "io" + "os" + "path/filepath" + "time" + + _ "github.com/gen2brain/webp" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Artwork", func() { + var aw *artwork + var ds model.DataStore + var ffmpeg *tests.MockFFmpeg + var folderRepo *fakeFolderRepo + ctx := log.NewContext(context.TODO()) + var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album + var arMultipleCovers model.Artist + var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.ImageCacheSize = "0" // Disable cache + conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*" + + folderRepo = &fakeFolderRepo{} + libRepo := &tests.MockLibraryRepo{} + repoRoot, _ := os.Getwd() + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ds = &tests.MockDataStore{ + MockedTranscoding: &tests.MockTranscodingRepo{}, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + // Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB. + alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} + alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} + alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}} + alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}} + alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}} + arMultipleCovers = model.Artist{ID: "777", Name: "All options"} + alMultipleCovers = model.Album{ + ID: "666", + Name: "All options", + EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", + FolderIDs: []string{"f1"}, + AlbumArtistID: "777", + } + mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"} + mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"} + mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"} + mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"} + + cache := GetImageCache() + ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") + aw = NewArtwork(ds, cache, ffmpeg, nil).(*artwork) + }) + + Describe("albumArtworkReader", func() { + Context("ID not found", func() { + It("returns ErrNotFound if album is not in the DB", func() { + _, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT-FOUND"), nil) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + Context("Embed images", func() { + BeforeEach(func() { + folderRepo.result = nil + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alOnlyEmbed, + alEmbedNotFound, + }) + }) + It("returns embed cover", func() { + aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3")) + }) + It("returns ErrUnavailable if embed path is not available", func() { + ffmpeg.Error = errors.New("not available") + aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, _, err = aw.Reader(ctx) + Expect(err).To(MatchError(ErrUnavailable)) + }) + }) + Context("External images", func() { + BeforeEach(func() { + folderRepo.result = []model.Folder{} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alOnlyExternal, + alExternalNotFound, + }) + }) + It("returns external cover", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"front.png"}, + }} + aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png")) + }) + It("returns ErrUnavailable if external file is not available", func() { + folderRepo.result = []model.Folder{} + aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, _, err = aw.Reader(ctx) + Expect(err).To(MatchError(ErrUnavailable)) + }) + }) + Context("Multiple covers", func() { + BeforeEach(func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg", "front.png", "artist.png"}, + }} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alMultipleCovers, + }) + }) + DescribeTable("CoverArtPriority", + func(priority string, expected string) { + conf.Server.CoverArtPriority = priority + aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(expected)) + }, + Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"), + Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"), + Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"), + ) + }) + Context("LastUpdated", func() { + // Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header. + // It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate + // cached cover art when only the image file changes. + now := time.Now().Truncate(time.Second) + DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", + func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { + album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} + folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) + + ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + Expect(ar.LastUpdated()).To(Equal(expected)) + }, + Entry("album newer than images", now, now.Add(-1*time.Hour), now), + Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), + Entry("equal timestamps", now, now, now), + ) + }) + }) + Describe("discArtworkReader", func() { + Context("LastUpdated", func() { + // Regression test for #5377: same bug as albumArtworkReader — disc covers + // must also revalidate when the image file changes, not only when media files do. + now := time.Now().Truncate(time.Second) + DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", + func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { + album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} + folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"}, + }) + + artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil) + dr, err := newDiscArtworkReader(ctx, aw, artID) + Expect(err).ToNot(HaveOccurred()) + Expect(dr.LastUpdated()).To(Equal(expected)) + }, + Entry("album newer than images", now, now.Add(-1*time.Hour), now), + Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), + Entry("equal timestamps", now, now, now), + ) + }) + }) + Describe("artistArtworkReader", func() { + Context("Multiple covers", func() { + BeforeEach(func() { + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + folderRepo.result = []model.Folder{{ + LibraryPath: testFileLibPath(repoRoot), + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, + }} + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{ + arMultipleCovers, + }) + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alMultipleCovers, + }) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + mfAnotherWithEmbed, + }) + }) + DescribeTable("ArtistArtPriority", + func(priority string, expected string) { + conf.Server.ArtistArtPriority = priority + aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(filepath.ToSlash(path)).To(HaveSuffix(expected)) + }, + Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"), + Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"), + ) + }) + }) + Describe("mediafileArtworkReader", func() { + Context("ID not found", func() { + It("returns ErrNotFound if mediafile is not in the DB", func() { + _, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND")) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + Context("Embed images", func() { + BeforeEach(func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"front.png"}, + }} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alOnlyEmbed, + alOnlyExternal, + alSingleDisc, + }) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + mfWithEmbed, + mfWithoutEmbed, + mfCorruptedCover, + }) + }) + It("returns embed cover", func() { + aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID()) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("tests/fixtures/test.mp3")) + }) + It("returns embed cover if successfully extracted by ffmpeg", func() { + aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) + Expect(err).ToNot(HaveOccurred()) + r, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + data, _ := io.ReadAll(r) + Expect(data).ToNot(BeEmpty()) + Expect(path).To(Equal("tests/fixtures/test.ogg")) + }) + It("returns album cover if cannot read embed artwork", func() { + // Force fromTag to fail + mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg" + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed()) + // Simulate ffmpeg error + ffmpeg.Error = errors.New("not available") + + aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("al-444_0")) + }) + It("returns album cover if media file has no cover art", func() { + aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID)) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("al-444_0")) + }) + It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() { + mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2} + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed()) + + aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID)) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + // Should fall back to disc art, which itself falls back to album art + Expect(path).To(Equal("dc-444:2_0")) + }) + It("falls back to album cover art for single-disc albums even with a disc number", func() { + mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1} + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed()) + + aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID)) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + // Single-disc album should skip disc art and go straight to album art + Expect(path).To(Equal("al-888_0")) + }) + }) + }) + Describe("playlistArtworkReader", func() { + Describe("findPlaylistSidecarPath", func() { + It("discovers sidecar image next to playlist file", func() { + tmpDir := GinkgoT().TempDir() + plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u") + imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg") + Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) + + result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) + Expect(result).To(Equal(imgPath)) + }) + + It("returns empty string when no sidecar image exists", func() { + tmpDir := GinkgoT().TempDir() + plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u") + Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + + result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) + Expect(result).To(BeEmpty()) + }) + + It("returns empty string when playlist has no path", func() { + result := findPlaylistSidecarPath(GinkgoT().Context(), "") + Expect(result).To(BeEmpty()) + }) + + It("finds sidecar with different case base name", func() { + tmpDir := GinkgoT().TempDir() + plsPath := filepath.Join(tmpDir, "myplaylist.m3u") + imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg") + Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) + + result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) + Expect(result).To(Equal(imgPath)) + }) + }) + + Describe("fromPlaylistExternalImage", func() { + It("opens local path from ExternalImageURL", func() { + tmpDir := GinkgoT().TempDir() + imgPath := filepath.Join(tmpDir, "cover.jpg") + Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed()) + + reader := &playlistArtworkReader{ + pl: model.Playlist{ExternalImageURL: imgPath}, + } + r, path, err := reader.fromPlaylistExternalImage(ctx)() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + data, _ := io.ReadAll(r) + Expect(string(data)).To(Equal("external image data")) + r.Close() + }) + + It("returns nil when ExternalImageURL is empty", func() { + reader := &playlistArtworkReader{ + pl: model.Playlist{ExternalImageURL: ""}, + } + r, path, err := reader.fromPlaylistExternalImage(ctx)() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + + It("returns error when local file does not exist", func() { + reader := &playlistArtworkReader{ + pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"}, + } + r, _, err := reader.fromPlaylistExternalImage(ctx)() + Expect(err).To(HaveOccurred()) + Expect(r).To(BeNil()) + }) + + It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() { + conf.Server.EnableM3UExternalAlbumArt = false + + reader := &playlistArtworkReader{ + pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"}, + } + r, path, err := reader.fromPlaylistExternalImage(ctx)() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + + It("still opens local path when EnableM3UExternalAlbumArt is false", func() { + conf.Server.EnableM3UExternalAlbumArt = false + + tmpDir := GinkgoT().TempDir() + imgPath := filepath.Join(tmpDir, "cover.jpg") + Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed()) + + reader := &playlistArtworkReader{ + pl: model.Playlist{ExternalImageURL: imgPath}, + } + r, path, err := reader.fromPlaylistExternalImage(ctx)() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + }) + + Describe("resizedArtworkReader", func() { + BeforeEach(func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg", "front.png"}, + }} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alMultipleCovers, + }) + }) + When("Square is false", func() { + It("returns PNG if original image is a PNG", func() { + conf.Server.CoverArtPriority = "front.png" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) + Expect(img.Bounds().Size().X).To(Equal(15)) + Expect(img.Bounds().Size().Y).To(Equal(15)) + }) + It("returns JPEG if original image is not a PNG", func() { + conf.Server.CoverArtPriority = "cover.jpg" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(format).To(Equal("jpeg")) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) + When("When square is true", func() { + var alCover model.Album + + DescribeTable("resize", + func(srcFormat string, expectedFormat string, landscape bool, size int) { + coverFileName := "cover." + srcFormat + dirName := createImage(srcFormat, landscape, size) + alCover = model.Album{ + ID: "444", + Name: "Only external", + FolderIDs: []string{"tmp"}, + } + folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}} + rootLibRepo := &tests.MockLibraryRepo{} + rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) + ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alCover, + }) + + conf.Server.CoverArtPriority = coverFileName + r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal(expectedFormat)) + Expect(img.Bounds().Size().X).To(Equal(size)) + Expect(img.Bounds().Size().Y).To(Equal(size)) + }, + Entry("portrait png image", "png", "png", false, 200), + Entry("landscape png image", "png", "png", true, 200), + Entry("portrait jpg image", "jpg", "png", false, 200), + Entry("landscape jpg image", "jpg", "png", true, 200), + ) + }) + When("EnableWebPEncoding is true and square is false", func() { + BeforeEach(func() { + conf.Server.EnableWebPEncoding = true + }) + It("returns WebP even if original image is a PNG", func() { + conf.Server.CoverArtPriority = "front.png" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("webp")) + Expect(img.Bounds().Size().X).To(Equal(15)) + Expect(img.Bounds().Size().Y).To(Equal(15)) + }) + It("returns WebP if original image is not a PNG", func() { + conf.Server.CoverArtPriority = "cover.jpg" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(format).To(Equal("webp")) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) + When("EnableWebPEncoding is false and square is false", func() { + BeforeEach(func() { + conf.Server.EnableWebPEncoding = false + }) + It("returns PNG if original image is a PNG", func() { + conf.Server.CoverArtPriority = "front.png" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) + Expect(img.Bounds().Size().X).To(Equal(15)) + Expect(img.Bounds().Size().Y).To(Equal(15)) + }) + It("returns JPEG if original image is a JPG", func() { + conf.Server.CoverArtPriority = "cover.jpg" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("jpeg")) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) + When("EnableWebPEncoding is false and square is true", func() { + var alCover model.Album + + BeforeEach(func() { + conf.Server.EnableWebPEncoding = false + }) + It("returns PNG for square mode", func() { + dirName := createImage("png", false, 200) + alCover = model.Album{ + ID: "444", + Name: "Only external", + FolderIDs: []string{"tmp"}, + } + folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}} + rootLibRepo := &tests.MockLibraryRepo{} + rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) + ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover}) + + conf.Server.CoverArtPriority = "cover.png" + r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) + When("Requested size is larger than original", func() { + It("clamps size to original dimensions", func() { + conf.Server.CoverArtPriority = "front.png" + // front.png is 16x16, requesting 99999 should return at original size + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false) + Expect(err).ToNot(HaveOccurred()) + + img, _, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + // Should be clamped to original size (16), not 99999 + Expect(img.Bounds().Size().X).To(Equal(16)) + Expect(img.Bounds().Size().Y).To(Equal(16)) + }) + + It("clamps square size to original dimensions", func() { + conf.Server.CoverArtPriority = "front.png" + // front.png is 16x16, requesting 99999 with square should return 16x16 square + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true) + Expect(err).ToNot(HaveOccurred()) + + img, _, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + // Should be clamped to original size (16), not 99999 + Expect(img.Bounds().Size().X).To(Equal(16)) + Expect(img.Bounds().Size().Y).To(Equal(16)) + }) + }) + }) +}) + +func createImage(format string, landscape bool, size int) string { + var img image.Image + + if landscape { + img = image.NewRGBA(image.Rect(0, 0, size, size/2)) + } else { + img = image.NewRGBA(image.Rect(0, 0, size/2, size)) + } + + tmpDir := GinkgoT().TempDir() + f, _ := os.Create(filepath.Join(tmpDir, "cover."+format)) + defer f.Close() + switch format { + case "png": + _ = png.Encode(f, img) + case "jpg": + _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75}) + } + + return tmpDir +} diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index 921a77b89..d42d7f3e4 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -1,55 +1,31 @@ package artwork import ( - "context" "io/fs" - "net/netip" "net/url" "os" "path/filepath" "runtime" "strings" "testing" - "time" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/httpclient" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "go.uber.org/goleak" ) func TestArtwork(t *testing.T) { - // Runs unconditionally: the two leaks below are pre-existing and out of this - // package's control, so they're ignored by exact top-function instead. - defer goleak.VerifyNone(t, - goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - // notify's own init() starts a singleton tree the moment it's imported (via - // core/storage/local or plugins); recursive on darwin, nonrecursive on linux. - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), - ) - tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "Artwork Suite") } -// productionImageClient keeps the guarded client for the specs that assert it refuses loopback. -var productionImageClient = remoteImageClient - -// httptest servers listen on loopback, which the production client refuses. -var _ = BeforeSuite(func() { - remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")) -}) - // osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. +// ReadTags is not used by albumArtworkReader, so it is left as a stub. type osDirFS struct{ fs.FS } func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil } @@ -93,37 +69,3 @@ func (s *osDirStorage) FS() (storage.MusicFS, error) { } return osDirFS{os.DirFS(s.root)}, nil } - -// fakeFolderRepo covers the three FolderRepository methods the resolvers reach for. The zero value -// answers as an unremarkable library does; the fields drive the album-root lookup and its failures. -type fakeFolderRepo struct { - model.FolderRepository - result []model.Folder - err error - parentResult *model.Folder - getErr error - getCallCount int - // 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(context.Context, ...model.QueryOptions) ([]model.Folder, error) { - return f.result, f.err -} - -func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) { - return f.hasOtherAudio, f.otherAudioErr -} - -func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) { - f.getCallCount++ - if f.getErr != nil { - return nil, f.getErr - } - if f.parentResult != nil { - return f.parentResult, nil - } - return nil, model.ErrNotFound -} diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index 2e8ed070e..adddd0dc3 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -1,557 +1,57 @@ -package artwork +package artwork_test import ( - "bytes" "context" - "image" "io" - "os" - "path/filepath" - "time" "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/model" "github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/cache" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Artwork", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - artRepo *tests.MockArtworkRepo - queueRepo *tests.MockArtworkQueueRepo - albumRepo *tests.MockAlbumRepo - mfRepo *tests.MockMediaFileRepo - folderRepo *fakeFolderRepo - libRepo *tests.MockLibraryRepo - ffm *tests.MockFFmpeg - store *ImageStore - imgCache cache.FileCache - svc Artwork - repoRoot string - coverBytes []byte - seedEntity func(kind, id string) - ) - - primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary } - - seedFoundStore := func(kind, id string, imgBytes []byte) string { - hash, err := hashImage(bytes.NewReader(imgBytes)) - Expect(err).ToNot(HaveOccurred()) - Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed()) - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed()) - seedEntity(kind, id) - return hash - } - - // Without its owning entity, a state row is not served at all. - seedEntity = func(kind, id string) { - GinkgoHelper() - switch kind { - case "al": - Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed()) - case "mf": - Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed()) - } - } - - readAll := func(img *Image) []byte { - GinkgoHelper() - defer img.Close() - data, err := io.ReadAll(img) - Expect(err).ToNot(HaveOccurred()) - return data - } + var aw artwork.Artwork + var ds model.DataStore + var ffmpeg *tests.MockFFmpeg BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - var err error - repoRoot, err = os.Getwd() - Expect(err).ToNot(HaveOccurred()) - coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) - Expect(err).ToNot(HaveOccurred()) + conf.Server.ImageCacheSize = "0" // Disable cache + cache := artwork.GetImageCache() + ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") + aw = artwork.NewArtwork(ds, cache, ffmpeg, nil) + }) - conf.Server.EnableWebPEncoding = false - conf.Server.CoverArtQuality = 75 - conf.Server.CoverArtPriority = "cover.*" - conf.Server.DiscArtPriority = "cover.*" - conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + Context("GetOrPlaceholder", func() { + Context("Empty ID", func() { + It("returns placeholder if album is not in the DB", func() { + r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false) + Expect(err).ToNot(HaveOccurred()) - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - albumRepo = tests.CreateMockAlbumRepo() - mfRepo = tests.CreateMockMediaFileRepo() - folderRepo = &fakeFolderRepo{} - libRepo = &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - ds = &tests.MockDataStore{ - MockedArtwork: artRepo, - MockedArtworkQueue: queueRepo, - MockedAlbum: albumRepo, - MockedMediaFile: mfRepo, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - ffm = tests.NewMockFFmpeg("") - store = NewImageStore(GinkgoT().TempDir()) - imgCache = cache.NewFileCache("ServingTest", "100MB", "images", 0, - func(ctx context.Context, arg cache.Item) (io.Reader, error) { - return arg.(artworkReader).Reader(ctx) + ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + phBytes, err := io.ReadAll(ph) + Expect(err).ToNot(HaveOccurred()) + + result, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + + Expect(result).To(Equal(phBytes)) }) - Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) - svc = NewArtwork(ds, imgCache, store, ffm) - }) - - Describe("found state", func() { - It("serves a store-backed found image sized (cache miss resizes, second call is a cache hit)", func() { - seedFoundStore("al", "al1", coverBytes) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) - Expect(err).ToNot(HaveOccurred()) - // A resized response versions its ETag with the encode settings, not the pixel hash. - Expect(img.ETag).To(Equal(representationTag(img.Hash, 100, false))) - Expect(img.ETag).ToNot(Equal(img.Hash)) - resized := readAll(img) - cfg, _, err := image.DecodeConfig(bytes.NewReader(resized)) - Expect(err).ToNot(HaveOccurred()) - Expect(cfg.Width).To(Equal(100)) - - // Deleting the store file proves the warm entry serves without touching the original. - hash, _ := hashImage(bytes.NewReader(coverBytes)) - Expect(os.Remove(store.path(hash, "image/jpeg"))).To(Succeed()) - Eventually(func(g Gomega) { - img2, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(readAll(img2)).To(Equal(resized)) - }).Should(Succeed()) - }) - - It("treats a negative size as a full-size request, not a giant resize", func() { - seedFoundStore("al", "alneg", coverBytes) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-alneg"), -2000000000, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes), "original bytes, no resize (would OOM)") - }) - - It("streams a file-backed found image at full size", func() { - dir := GinkgoT().TempDir() - imgPath := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) - mtime := fileMtime(imgPath) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed()) - seedEntity("al", "al2") - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa", - Source: "folder", SourcePath: imgPath, RefMtime: mtime, - })).To(Succeed()) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("treats a file-backed row pointing at a non-image file as dangling", func() { - dir := GinkgoT().TempDir() - secretPath := filepath.Join(dir, "config.ini") - Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed()) - seedEntity("al", "alni") - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd", - Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), - })).To(Succeed()) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan)) - }) - - It("refuses a non-image file-backed row even when a resized copy is already cached", func() { - secret := []byte("password=secret") - dir := GinkgoT().TempDir() - secretPath := filepath.Join(dir, "config.ini") - Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed()) - seedEntity("al", "alnic") - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee", - Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), - })).To(Succeed()) - - // Older versions cached the raw bytes when the resize failed. - seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil } - stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm}) - Expect(err).ToNot(HaveOccurred()) - Expect(io.ReadAll(stream)).To(Equal(secret)) - Expect(stream.Close()).To(Succeed()) - Eventually(func(g Gomega) { - s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm, - open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }}) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(s.Cached).To(BeTrue()) - _ = s.Close() - }).Should(Succeed()) - - _, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan)) - }) - - It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() { - dir := GinkgoT().TempDir() - imgPath := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed()) - seedEntity("al", "al3") - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb", - Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, - })).To(Succeed()) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan)) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb")) - }) - - It("enforces the mtime rule on the sized (loader) path too", func() { - dir := GinkgoT().TempDir() - imgPath := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed()) - seedEntity("al", "al3b") - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc", - Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, - })).To(Succeed()) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-al3b"), 100, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "al3b")].Priority).To(Equal(model.ArtworkPriorityScan)) - }) - - // State rows outlive a deleted entity until the next prune. - It("refuses to serve a found row whose entity is gone", func() { - hash := seedFoundStore("al", "alzz", coverBytes) - Expect(hash).ToNot(BeEmpty()) - albumRepo.SetData(model.Albums{}) // the album is deleted; its artwork row survives - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-alzz"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - }) - - It("never re-enqueues an absent state on view, however old", func() { - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour), - })).To(Succeed()) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data).To(BeEmpty()) }) }) - - Describe("provisional read-through", func() { - It("serves local folder art, enqueues a Bump, and writes no state row", func() { - folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: "al5", Name: "Album", FolderIDs: []string{"f1"}}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-al5"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - - Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump)) - _, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("returns ErrUnavailable and enqueues a Bump when nothing local resolves", func() { - folderRepo.result = nil - albumRepo.SetData(model.Albums{{ID: "al6", Name: "Album"}}) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump)) - _, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("media file", func() { - It("serves a track's own found art", func() { - seedFoundStore("mf", "mf1", coverBytes) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() { - conf.Server.EnableMediaFileCoverArt = false - seedFoundStore("mf", "mf7", []byte("stale embedded track art")) - seedFoundStore("al", "albz", coverBytes) - mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf7"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes), "album art, not the persisted embedded art") - }) - - It("delegates to the album when the track's state is absent", func() { - seedFoundStore("al", "albm", coverBytes) - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed()) - mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - _, mfEnq := queueRepo.Data[primaryKey("mf", "mf2")] - Expect(mfEnq).To(BeFalse()) - }) - - It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() { - conf.Server.EnableMediaFileCoverArt = true - seedFoundStore("al", "albn", coverBytes) - mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf3"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - _, mfEnq := queueRepo.Data[primaryKey("mf", "mf3")] - Expect(mfEnq).To(BeFalse()) - }) - - It("extracts embedded art provisionally and enqueues the track when eligible", func() { - conf.Server.EnableMediaFileCoverArt = true - mfRepo.SetData(model.MediaFiles{{ - ID: "mf4", AlbumID: "albo", HasCoverArt: true, - Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0, - }}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf4"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(len(readAll(img))).To(BeNumerically(">", 0)) - Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump)) - _, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("delegates a multi-disc track to its disc art, not straight to the album", func() { - folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "One", 2: "Two"}}}) - seedFoundStore("al", "aldd", []byte("album-art-distinct")) // album's own found art differs - mfRepo.SetData(model.MediaFiles{{ID: "mf5", AlbumID: "aldd", DiscNumber: 1, HasCoverArt: false}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf5"), 0, false) - Expect(err).ToNot(HaveOccurred()) - // The disc-folder image, not the album's own art: proof it routed through serveDisc. - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("falls back to the album when an eligible track's embedded art will not extract", func() { - conf.Server.EnableMediaFileCoverArt = true - // HasCoverArt is set, but the file is not audio, so nothing extracts. - mfRepo.SetData(model.MediaFiles{{ - ID: "mfbad", AlbumID: "albad", LibraryID: 0, HasCoverArt: true, - Path: "tests/fixtures/artist/an-album/front.png", - }}) - albumRepo.SetData(model.Albums{{ID: "albad", Name: "Album"}}) - seedFoundStore("al", "albad", coverBytes) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mfbad"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes), "a placeholder here would be worse than the album cover") - }) - - It("routes a single-disc track through disc resolution too", func() { - // DiscArtPriority applies to single-disc albums too, over the album's own found art. - folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: "alsd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}}) - seedFoundStore("al", "alsd", []byte("album-art-distinct")) - mfRepo.SetData(model.MediaFiles{{ID: "mf6", AlbumID: "alsd", DiscNumber: 1, HasCoverArt: false}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf6"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - }) - - Describe("disc", func() { - It("serves a local disc-folder image", func() { - folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: "aldc", Name: "Album", FolderIDs: []string{"f1"}}}) - - img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc", 1), nil), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - // The resize cache keys on id + album mtime, not on the bytes, so dropping the source - // between the two requests is what shows a warm hit never touches the filesystem. - It("serves a sized disc image from cache without re-reading the source", func() { - folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: "aldc3", Name: "Album", FolderIDs: []string{"f1"}}}) - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc3", 1), nil) - - first, err := svc.Get(ctx, discID, 64, false) - Expect(err).ToNot(HaveOccurred()) - warmed := readAll(first) - Expect(warmed).ToNot(BeEmpty()) - - folderRepo.result = nil - - second, err := svc.Get(ctx, discID, 64, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(second)).To(Equal(warmed), "a warm sized request must not touch the source") - }) - - // A disc image can change without the album row changing, so the key folds in ImagesUpdatedAt. - It("invalidates the cached image when the folder's images change", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, - ImagesUpdatedAt: time.Now().Add(-time.Hour), - }} - albumRepo.SetData(model.Albums{{ID: "aldc4", Name: "Album", FolderIDs: []string{"f1"}}}) - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc4", 1), nil) - - first, err := svc.Get(ctx, discID, 64, false) - Expect(err).ToNot(HaveOccurred()) - firstKey := first.ETag - readAll(first) - - folderRepo.result[0].ImagesUpdatedAt = time.Now() - second, err := svc.Get(ctx, discID, 64, false) - Expect(err).ToNot(HaveOccurred()) - readAll(second) - Expect(second.ETag).ToNot(Equal(firstKey), "a replaced image must not keep the old cache entry") - }) - - // Disc art has no content hash, so without an explicit validator every ETag would be empty. - It("gives a full-size disc image a validator that tracks the source", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, - ImagesUpdatedAt: time.Now().Add(-time.Hour), - }} - albumRepo.SetData(model.Albums{{ID: "aldc5", Name: "Album", FolderIDs: []string{"f1"}}}) - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc5", 1), nil) - - first, err := svc.Get(ctx, discID, 0, false) - Expect(err).ToNot(HaveOccurred()) - readAll(first) - Expect(first.ETag).ToNot(BeEmpty()) - - folderRepo.result[0].ImagesUpdatedAt = time.Now() - second, err := svc.Get(ctx, discID, 0, false) - Expect(err).ToNot(HaveOccurred()) - readAll(second) - Expect(second.ETag).ToNot(Equal(first.ETag), "a replaced image must not revalidate as unchanged") - }) - - It("falls back to album art when no disc image matches", func() { - folderRepo.result = nil - albumRepo.SetData(model.Albums{{ID: "aldc2", Name: "Album"}}) - seedFoundStore("al", "aldc2", coverBytes) - - img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc2", 1), nil), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - }) - - Describe("GetOrPlaceholder", func() { - It("accepts a raw entity id and serves its cover art", func() { - albumRepo.SetData(model.Albums{{ID: "rawal", Name: "Album"}}) - seedFoundStore("al", "rawal", coverBytes) - - img, err := svc.GetOrPlaceholder(ctx, "rawal", 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Placeholder).To(BeFalse()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("falls back to the album placeholder ignoring size and square", func() { - img, err := svc.GetOrPlaceholder(ctx, "", 300, true) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Placeholder).To(BeTrue()) - Expect(img.Hash).To(BeEmpty()) - Expect(img.LastUpdated).To(BeZero()) - - ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) - Expect(err).ToNot(HaveOccurred()) - phBytes, _ := io.ReadAll(ph) - Expect(readAll(img)).To(Equal(phBytes)) - }) - - It("falls back to the artist placeholder for an absent artist", func() { - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed()) - - img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Placeholder).To(BeTrue()) - - ph, err := resources.FS().Open(consts.PlaceholderArtistArt) - Expect(err).ToNot(HaveOccurred()) - phBytes, _ := io.ReadAll(ph) - Expect(readAll(img)).To(Equal(phBytes)) - }) - - // "No art" and "no such entity" are different answers: clients 404 only on the latter. - It("reports not-found rather than a placeholder for an id with no entity", func() { - _, err := svc.GetOrPlaceholder(ctx, "al-nosuchalbum", 0, false) - Expect(err).To(MatchError(model.ErrNotFound)) - - _, err = svc.GetOrPlaceholder(ctx, "nosuchrawid", 0, false) - Expect(err).To(MatchError(model.ErrNotFound)) + Context("Get", func() { + Context("Empty ID", func() { + It("returns an ErrUnavailable error", func() { + _, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false) + Expect(err).To(MatchError(artwork.ErrUnavailable)) + }) }) }) }) - -func fileMtime(path string) int64 { - GinkgoHelper() - info, err := os.Stat(path) - Expect(err).ToNot(HaveOccurred()) - return info.ModTime().UnixNano() -} - -var _ = Describe("EntityExists", func() { - var ctx context.Context - var ds *tests.MockDataStore - - BeforeEach(func() { - ctx = context.Background() - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{{ID: "al1"}}) - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar1"}}) - radioRepo := tests.CreateMockedRadioRepo() - Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed()) - ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo} - }) - - DescribeTable("reports whether the owning entity is still there", - func(id string, expected bool) { - Expect(entityExists(ctx, ds, model.MustParseArtworkID(id))).To(Equal(expected)) - }, - Entry("existing album", "al-al1", true), - Entry("deleted album", "al-gone", false), - Entry("existing artist", "ar-ar1", true), - Entry("deleted artist", "ar-gone", false), - Entry("existing radio", "ra-ra1", true), - Entry("deleted radio", "ra-gone", false), - // Disc art has no entity of its own; it stands or falls with its album. - Entry("disc of an existing album", "dc-al1:1", true), - Entry("disc of a deleted album", "dc-gone:1", false), - Entry("malformed disc id", "dc-nodiscnum", false), - ) -}) diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go new file mode 100644 index 000000000..c27964018 --- /dev/null +++ b/core/artwork/benchmark_e2e_test.go @@ -0,0 +1,189 @@ +package artwork + +import ( + "context" + "fmt" + "image/jpeg" + "io" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" +) + +// setupE2EBenchmark creates an artwork instance with a real album cover image on disk, +// backed by either a real file cache or disabled cache depending on cacheSize. +// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers +// the critical path (source selection, decode, resize, encode, cache). This is a deliberate +// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure +// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant. +// +// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together). +func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) { + b.Helper() + cleanup := configtest.SetupConfig() + b.Cleanup(cleanup) + + tmpDir, err := os.MkdirTemp("", "artwork-bench-*") + if err != nil { + b.Fatal(err) + } + + // Create a realistic cover image on disk + coverPath := filepath.Join(tmpDir, "cover.jpg") + coverImg := generateGradientImage(1000, 1000) + f, err := os.Create(coverPath) + if err != nil { + b.Fatal(err) + } + if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil { + f.Close() + b.Fatal(err) + } + f.Close() + + // Configure cache + conf.Server.ImageCacheSize = cacheSize + conf.Server.CacheFolder = tmpDir + conf.Server.CoverArtQuality = 75 + conf.Server.CoverArtPriority = "cover.*" + + // Set up mock data store with album pointing to our cover. + // Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls. + album := model.Album{ + ID: "bench-album-1", + Name: "Benchmark Album", + FolderIDs: []string{"f1"}, + UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + } + folderRepo := &fakeFolderRepo{ + result: []model.Folder{{ + Path: tmpDir, + ImageFiles: []string{"cover.jpg"}, + }}, + } + ds := &tests.MockDataStore{ + MockedTranscoding: &tests.MockTranscodingRepo{}, + MockedFolder: folderRepo, + } + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album}) + + artID := album.CoverArtID() + + imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0, + func(ctx context.Context, arg cache.Item) (io.Reader, error) { + r, _, err := arg.(artworkReader).Reader(ctx) + return r, err + }) + + // Wait for cache init if enabled + if cacheSize != "0" { + for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) { + runtime.Gosched() // Yield to allow background init goroutine to run + } + } + + ffmpeg := tests.NewMockFFmpeg("fallback content") + aw := NewArtwork(ds, imgCache, ffmpeg, nil) + + cleanupAll := func() { + os.RemoveAll(tmpDir) + } + return aw, artID, cleanupAll +} + +func BenchmarkArtworkGetE2E(b *testing.B) { + cacheConfigs := []struct { + name string + cacheSize string + }{ + {"no_cache", "0"}, + {"with_cache", "100MB"}, + } + sizes := []int{0, 300} + + for _, cc := range cacheConfigs { + for _, size := range sizes { + b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) { + aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) + defer cleanup() + + // Warm the cache on first call if cache is enabled + if cc.cacheSize != "0" { + r, _, err := aw.Get(context.Background(), artID, size, size > 0) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(r) + r.Close() + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + r, _, err := aw.Get(context.Background(), artID, size, size > 0) + if err != nil { + b.Fatal(err) + } + _, _ = io.ReadAll(r) + r.Close() + } + }) + } + } +} + +func BenchmarkArtworkGetE2EConcurrent(b *testing.B) { + cacheConfigs := []struct { + name string + cacheSize string + }{ + {"no_cache", "0"}, + {"with_cache", "100MB"}, + } + concurrencyLevels := []int{10, 50} + + for _, cc := range cacheConfigs { + for _, n := range concurrencyLevels { + b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) { + aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) + defer cleanup() + + // Warm cache + if cc.cacheSize != "0" { + r, _, _ := aw.Get(context.Background(), artID, 300, true) + if r != nil { + _, _ = io.ReadAll(r) + r.Close() + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + var wg sync.WaitGroup + wg.Add(n) + for g := 0; g < n; g++ { + go func() { + defer wg.Done() + r, _, err := aw.Get(context.Background(), artID, 300, true) + if err != nil { + b.Error(err) + return + } + _, _ = io.ReadAll(r) + r.Close() + }() + } + wg.Wait() + } + }) + } + } +} diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go index f3201d412..60990bb8b 100644 --- a/core/artwork/benchmark_helpers_test.go +++ b/core/artwork/benchmark_helpers_test.go @@ -4,7 +4,6 @@ import ( "bytes" "image" "image/color" - "image/draw" "image/jpeg" "image/png" "testing" @@ -36,8 +35,8 @@ func generatePNG(t testing.TB, width, height int) []byte { // generateGradientImage creates an RGBA image with a diagonal gradient pattern. func generateGradientImage(width, height int) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, width, height)) - for y := range height { - for x := range width { + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { r := uint8((x * 255) / width) g := uint8((y * 255) / height) b := uint8(((x + y) * 255) / (width + height)) @@ -46,11 +45,3 @@ func generateGradientImage(width, height int) *image.RGBA { } return img } - -// gradientNRGBA mirrors generateGradientImage in the type makeThumbnail hands the encoders. -func gradientNRGBA(size int) *image.NRGBA { - src := generateGradientImage(size, size) - dst := image.NewNRGBA(src.Bounds()) - draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src) - return dst -} diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go deleted file mode 100644 index 29e1ccfda..000000000 --- a/core/artwork/blurhash/blurhash.go +++ /dev/null @@ -1,208 +0,0 @@ -// Package blurhash implements the blurhash encoding (https://github.com/woltapp/blurhash), -// parameterized to match Jellyfin so clients see equivalent hashes. -package blurhash - -import ( - "errors" - "image" - "image/draw" - "math" - "strings" - "sync" - - xdraw "golang.org/x/image/draw" -) - -const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" - -// maxInputSize: larger inputs are slower with no visible difference in the result. -const maxInputSize = 128 - -// components picks x/y component counts targeting ~16 near-square tiles. -func components(width, height int) (int, int) { - xf := math.Sqrt(16.0 * float64(width) / float64(height)) - yf := xf * float64(height) / float64(width) - return min(int(xf)+1, 9), min(int(yf)+1, 9) -} - -// Encode returns the blurhash of img, deriving the component counts from its aspect ratio. -func Encode(img image.Image) (string, error) { - if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 { - return "", errors.New("blurhash: empty image") - } - // Pre-downscale: its rounding can flip a component count, and the hash is a client cache key. - xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy()) - src := pixelsOf(downscale(img)) - w, h := src.w, src.h - - cosX := make([][]float64, xComp) - for i := range cosX { - cosX[i] = make([]float64, w) - for x := range cosX[i] { - cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w)) - } - } - cosY := make([][]float64, yComp) - for j := range cosY { - cosY[j] = make([]float64, h) - for y := range cosY[j] { - cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h)) - } - } - - lin := srgbToLinearTable() - factors := make([][3]float64, xComp*yComp) - linR := make([]float64, w) - linG := make([]float64, w) - linB := make([]float64, w) - rowR := make([]float64, xComp) - rowG := make([]float64, xComp) - rowB := make([]float64, xComp) - for y := range h { - row := src.pix[y*src.stride:] - for x := range w { - p := x * 4 - r, g, b := row[p], row[p+1], row[p+2] - if src.straight { - r, g, b = premultiply(r, g, b, row[p+3]) - } - linR[x], linG[x], linB[x] = lin[r], lin[g], lin[b] - } - // The basis is separable, so a row costs xComp dot products plus one fold over yComp, - // rather than xComp*yComp multiply-accumulates per pixel. - for i := range xComp { - var sr, sg, sb float64 - for x, c := range cosX[i] { - sr += c * linR[x] - sg += c * linG[x] - sb += c * linB[x] - } - rowR[i], rowG[i], rowB[i] = sr, sg, sb - } - for j := range yComp { - cy := cosY[j][y] - for i := range xComp { - f := &factors[j*xComp+i] - f[0] += cy * rowR[i] - f[1] += cy * rowG[i] - f[2] += cy * rowB[i] - } - } - } - for idx := range factors { - norm := 2.0 - if idx == 0 { - norm = 1.0 - } - scale := norm / float64(w*h) - factors[idx][0] *= scale - factors[idx][1] *= scale - factors[idx][2] *= scale - } - - var sb strings.Builder - sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1)) - - // Derived counts are at least 1x9, so there is always at least one AC factor. - ac := factors[1:] - actualMax := 0.0 - for _, f := range ac { - actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2])) - } - quantMax := int(max(0, min(82, math.Floor(actualMax*166-0.5)))) - maxVal := float64(quantMax+1) / 166 - sb.WriteString(encode83(quantMax, 1)) - - dc := factors[0] - sb.WriteString(encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4)) - for _, f := range ac { - sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) - } - return sb.String(), nil -} - -// pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At. -type pixels struct { - pix []uint8 - stride int - w, h int - // straight marks non-premultiplied alpha, which the loop premultiplies to keep the hash - // identical to the one an equivalent *image.RGBA produces. - straight bool -} - -// pixelsOf accepts the two types the artwork pipeline produces without copying, and converts -// anything else. -func pixelsOf(img image.Image) pixels { - b := img.Bounds() - switch src := img.(type) { - case *image.RGBA: - return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy()} - case *image.NRGBA: - return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy(), straight: true} - } - dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) - draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) - return pixels{pix: dst.Pix, stride: dst.Stride, w: b.Dx(), h: b.Dy()} -} - -func premultiply(r, g, b, a uint8) (uint8, uint8, uint8) { - if a == 255 { - return r, g, b - } - return uint8(uint32(r) * uint32(a) / 255), uint8(uint32(g) * uint32(a) / 255), uint8(uint32(b) * uint32(a) / 255) -} - -var srgbToLinearTable = sync.OnceValue(func() *[256]float64 { - var t [256]float64 - for i := range t { - t[i] = srgbToLinear(i) - } - return &t -}) - -func downscale(img image.Image) image.Image { - b := img.Bounds() - w, h := b.Dx(), b.Dy() - if w <= maxInputSize && h <= maxInputSize { - return img - } - scale := float64(maxInputSize) / float64(max(w, h)) - dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) - xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) - return dst -} - -func quantAC(v, maxVal float64) int { - return int(max(0, min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5)))) -} - -func signPow(v, exp float64) float64 { - return math.Copysign(math.Pow(math.Abs(v), exp), v) -} - -func srgbToLinear(v int) float64 { - f := float64(v) / 255 - if f <= 0.04045 { - return f / 12.92 - } - return math.Pow((f+0.055)/1.055, 2.4) -} - -func linearToSRGB(v float64) int { - v = min(max(0, v), 1) - if v <= 0.0031308 { - return int(v*12.92*255 + 0.5) - } - return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5) -} - -// encode83 encodes value as a fixed-width, big-endian base83 string of the given length. -func encode83(value, length int) string { - b := make([]byte, length) - for i := length - 1; i >= 0; i-- { - b[i] = alphabet[value%83] - value /= 83 - } - return string(b) -} diff --git a/core/artwork/blurhash/blurhash_suite_test.go b/core/artwork/blurhash/blurhash_suite_test.go deleted file mode 100644 index b0e96d7a7..000000000 --- a/core/artwork/blurhash/blurhash_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package blurhash_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestBlurHash(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "BlurHash Suite") -} diff --git a/core/artwork/blurhash/blurhash_test.go b/core/artwork/blurhash/blurhash_test.go deleted file mode 100644 index 91187165e..000000000 --- a/core/artwork/blurhash/blurhash_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package blurhash_test - -import ( - "image" - "image/color" - "strings" - - "github.com/navidrome/navidrome/core/artwork/blurhash" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" - -func decode83(s string) int { - v := 0 - for _, c := range s { - v = v*83 + strings.IndexRune(alphabet, c) - } - return v -} - -func solidImage(w, h int, c color.NRGBA) image.Image { - img := image.NewNRGBA(image.Rect(0, 0, w, h)) - for y := range h { - for x := range w { - img.SetNRGBA(x, y, c) - } - } - return img -} - -func gradientImage(w, h int) image.Image { - img := image.NewNRGBA(image.Rect(0, 0, w, h)) - for y := range h { - for x := range w { - img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255}) - } - } - return img -} - -var _ = Describe("Encode input types", func() { - // The pipeline hands Encode an *image.NRGBA; reading it must stay equivalent to the - // premultiplied *image.RGBA it used to receive, or every hash silently shifts. - buildPair := func(alpha uint8) (*image.NRGBA, *image.RGBA) { - const size = 40 - nrgba := image.NewNRGBA(image.Rect(0, 0, size, size)) - rgba := image.NewRGBA(image.Rect(0, 0, size, size)) - for y := range size { - for x := range size { - c := color.NRGBA{ - R: uint8(255 * x / size), G: uint8(255 * y / size), - B: uint8((x + y) * 255 / (2 * size)), A: alpha, - } - nrgba.SetNRGBA(x, y, c) - rgba.Set(x, y, c) // image.RGBA.Set premultiplies - } - } - return nrgba, rgba - } - - DescribeTable("gives an NRGBA the same hash as the premultiplied RGBA it replaces", - func(alpha uint8) { - nrgba, rgba := buildPair(alpha) - fromNRGBA, err := blurhash.Encode(nrgba) - Expect(err).ToNot(HaveOccurred()) - fromRGBA, err := blurhash.Encode(rgba) - Expect(err).ToNot(HaveOccurred()) - Expect(fromNRGBA).To(Equal(fromRGBA)) - }, - Entry("opaque", uint8(255)), - Entry("partly transparent", uint8(128)), - Entry("fully transparent, which premultiplication crushes to black", uint8(0)), - ) -}) - -var _ = Describe("Encode", func() { - // The size flag encodes (xComp-1) + (yComp-1)*9. - DescribeTable("derives component counts from aspect ratio (Jellyfin formula)", - func(w, h, expectedX, expectedY int) { - hash, err := blurhash.Encode(gradientImage(w, h)) - Expect(err).ToNot(HaveOccurred()) - Expect(decode83(hash[:1])).To(Equal((expectedX - 1) + (expectedY-1)*9)) - }, - Entry("square album art", 60, 60, 5, 5), - Entry("smallest square", 1, 1, 5, 5), - Entry("landscape 16:9", 192, 108, 6, 4), - Entry("portrait 9:16", 108, 192, 4, 6), - Entry("extreme landscape capped at 9", 1000, 10, 9, 1), - Entry("extreme portrait capped at 9", 10, 1000, 1, 9), - ) - - It("rejects an empty image", func() { - _, err := blurhash.Encode(image.NewNRGBA(image.Rect(0, 0, 0, 0))) - Expect(err).To(HaveOccurred()) - }) - - It("produces the spec-mandated length", func() { - // 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component; a square derives 5x5 - h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255})) - Expect(err).ToNot(HaveOccurred()) - Expect(h).To(HaveLen(4 + 2 + 2*(5*5-1))) - }) - - It("stores the average color in the DC component", func() { - h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255})) - Expect(err).ToNot(HaveOccurred()) - dc := decode83(h[2:6]) - Expect(dc >> 16).To(BeNumerically("~", 200, 1)) - Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1)) - Expect(dc & 0xFF).To(BeNumerically("~", 50, 1)) - }) - - It("is deterministic", func() { - img := gradientImage(64, 64) - h1, err1 := blurhash.Encode(img) - h2, err2 := blurhash.Encode(img) - Expect(err1).ToNot(HaveOccurred()) - Expect(err2).ToNot(HaveOccurred()) - Expect(h1).To(Equal(h2)) - }) - - It("produces different hashes for different images", func() { - h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255})) - h2, _ := blurhash.Encode(gradientImage(16, 16)) - Expect(h1).ToNot(Equal(h2)) - }) - - It("downscales large images internally without changing the result materially", func() { - big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255})) - Expect(err).ToNot(HaveOccurred()) - small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255})) - Expect(err).ToNot(HaveOccurred()) - Expect(big[2:6]).To(Equal(small[2:6])) - }) -}) diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go new file mode 100644 index 000000000..5090d638e --- /dev/null +++ b/core/artwork/cache_warmer.go @@ -0,0 +1,162 @@ +package artwork + +import ( + "context" + "fmt" + "io" + "maps" + "slices" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/pl" +) + +type CacheWarmer interface { + PreCache(artID model.ArtworkID) +} + +// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background +// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original +// image size, as well as the size defined by the UICoverArtSize config option. +func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { + // If image cache is disabled, return a NOOP implementation + if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache { + return &noopCacheWarmer{} + } + + // If the file cache is disabled, return a NOOP implementation + if cache.Disabled(context.Background()) { + log.Debug("Image cache disabled. Cache warmer will not run") + return &noopCacheWarmer{} + } + + a := &cacheWarmer{ + artwork: artwork, + cache: cache, + buffer: make(map[model.ArtworkID]struct{}), + wakeSignal: make(chan struct{}, 1), + coverArtSize: conf.Server.UICoverArtSize, + } + + // Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts + ctx := request.WithUser(context.TODO(), model.User{IsAdmin: true}) + go a.run(ctx) + return a +} + +type cacheWarmer struct { + artwork Artwork + buffer map[model.ArtworkID]struct{} + mutex sync.Mutex + cache cache.FileCache + wakeSignal chan struct{} + coverArtSize int +} + +func (a *cacheWarmer) PreCache(artID model.ArtworkID) { + if a.cache.Disabled(context.Background()) { + return + } + a.mutex.Lock() + defer a.mutex.Unlock() + a.buffer[artID] = struct{}{} + a.sendWakeSignal() +} + +func (a *cacheWarmer) sendWakeSignal() { + // Don't block if the previous signal was not read yet + select { + case a.wakeSignal <- struct{}{}: + default: + } +} + +func (a *cacheWarmer) run(ctx context.Context) { + for { + a.waitSignal(ctx, 10*time.Second) + if ctx.Err() != nil { + break + } + + if a.cache.Disabled(ctx) { + a.mutex.Lock() + pending := len(a.buffer) + a.buffer = make(map[model.ArtworkID]struct{}) + a.mutex.Unlock() + if pending > 0 { + log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending) + } + return + } + + // If cache not available, keep waiting + if !a.cache.Available(ctx) { + a.mutex.Lock() + bufferLen := len(a.buffer) + a.mutex.Unlock() + if bufferLen > 0 { + log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen) + } + continue + } + + a.mutex.Lock() + + // If there's nothing to send, keep waiting + if len(a.buffer) == 0 { + a.mutex.Unlock() + continue + } + + batch := slices.Collect(maps.Keys(a.buffer)) + a.buffer = make(map[model.ArtworkID]struct{}) + a.mutex.Unlock() + + a.processBatch(ctx, batch) + } +} + +func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) { + select { + case <-time.After(timeout): + case <-a.wakeSignal: + case <-ctx.Done(): + } +} + +func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) { + log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch)) + input := pl.FromSlice(ctx, batch) + errs := pl.Sink(ctx, 4, input, a.doCacheImage) + for err := range errs { + log.Debug(ctx, "Error warming cache", err) + } +} + +func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + size := a.coverArtSize + r, _, err := a.artwork.Get(ctx, id, size, true) + if err != nil { + return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) + } + _, err = io.Copy(io.Discard, r) + r.Close() + return err +} + +func NoopCacheWarmer() CacheWarmer { + return &noopCacheWarmer{} +} + +type noopCacheWarmer struct{} + +func (a *noopCacheWarmer) PreCache(model.ArtworkID) {} diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go new file mode 100644 index 000000000..a5da2004c --- /dev/null +++ b/core/artwork/cache_warmer_test.go @@ -0,0 +1,245 @@ +package artwork + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("CacheWarmer", func() { + var ( + fc *mockFileCache + aw *mockArtwork + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + fc = &mockFileCache{} + aw = &mockArtwork{} + }) + + Context("initialization", func() { + It("returns noop when cache is disabled", func() { + fc.SetDisabled(true) + cw := NewCacheWarmer(aw, fc) + _, ok := cw.(*noopCacheWarmer) + Expect(ok).To(BeTrue()) + }) + + It("returns noop when ImageCacheSize is 0", func() { + conf.Server.ImageCacheSize = "0" + cw := NewCacheWarmer(aw, fc) + _, ok := cw.(*noopCacheWarmer) + Expect(ok).To(BeTrue()) + }) + + It("returns noop when EnableArtworkPrecache is false", func() { + conf.Server.EnableArtworkPrecache = false + cw := NewCacheWarmer(aw, fc) + _, ok := cw.(*noopCacheWarmer) + Expect(ok).To(BeTrue()) + }) + + It("returns real implementation when properly configured", func() { + conf.Server.ImageCacheSize = "100MB" + conf.Server.EnableArtworkPrecache = true + fc.SetDisabled(false) + cw := NewCacheWarmer(aw, fc) + _, ok := cw.(*cacheWarmer) + Expect(ok).To(BeTrue()) + }) + }) + + Context("buffer management", func() { + BeforeEach(func() { + conf.Server.ImageCacheSize = "100MB" + conf.Server.EnableArtworkPrecache = true + fc.SetDisabled(false) + }) + + It("drops buffered items when cache becomes disabled", func() { + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-test")) + fc.SetDisabled(true) + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + }) + + It("adds multiple items to buffer", func() { + fc.SetReady(false) // Make cache unavailable so items stay in buffer + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-1")) + cw.PreCache(model.MustParseArtworkID("al-2")) + cw.mutex.Lock() + defer cw.mutex.Unlock() + Expect(len(cw.buffer)).To(Equal(2)) + }) + + It("deduplicates items in buffer", func() { + fc.SetReady(false) // Make cache unavailable so items stay in buffer + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-1")) + cw.PreCache(model.MustParseArtworkID("al-1")) + cw.mutex.Lock() + defer cw.mutex.Unlock() + Expect(len(cw.buffer)).To(Equal(1)) + }) + }) + + Context("error handling", func() { + BeforeEach(func() { + conf.Server.ImageCacheSize = "100MB" + conf.Server.EnableArtworkPrecache = true + fc.SetDisabled(false) + }) + + It("continues processing after artwork retrieval error", func() { + aw.err = errors.New("artwork error") + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-error")) + cw.PreCache(model.MustParseArtworkID("al-1")) + + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + }) + + It("continues processing after cache error", func() { + fc.err = errors.New("cache error") + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-error")) + cw.PreCache(model.MustParseArtworkID("al-1")) + + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + }) + }) + + Context("background processing", func() { + BeforeEach(func() { + conf.Server.ImageCacheSize = "100MB" + conf.Server.EnableArtworkPrecache = true + fc.SetDisabled(false) + }) + + It("processes items in batches", func() { + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + for i := range 5 { + cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i))) + } + + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + }) + + It("wakes up on new items", func() { + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + + // Add first batch + cw.PreCache(model.MustParseArtworkID("al-1")) + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + + // Add second batch + cw.PreCache(model.MustParseArtworkID("al-2")) + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + }) + + It("pre-caches UICoverArtSize", func() { + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-1")) + + Eventually(func() []int { + return aw.getCachedSizes() + }).Should(ContainElements(conf.Server.UICoverArtSize)) + }) + }) +}) + +type mockArtwork struct { + err error + mu sync.Mutex + cachedSizes []int +} + +func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) { + if m.err != nil { + return nil, time.Time{}, m.err + } + m.mu.Lock() + m.cachedSizes = append(m.cachedSizes, size) + m.mu.Unlock() + return io.NopCloser(strings.NewReader("test")), time.Now(), nil +} + +func (m *mockArtwork) getCachedSizes() []int { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]int, len(m.cachedSizes)) + copy(result, m.cachedSizes) + return result +} + +func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { + return m.Get(ctx, model.ArtworkID{}, size, square) +} + +type mockFileCache struct { + disabled atomic.Bool + ready atomic.Bool + err error +} + +func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) { + if f.err != nil { + return nil, f.err + } + return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil +} + +func (f *mockFileCache) Available(ctx context.Context) bool { + return f.ready.Load() && !f.disabled.Load() +} + +func (f *mockFileCache) Disabled(ctx context.Context) bool { + return f.disabled.Load() +} + +func (f *mockFileCache) SetDisabled(v bool) { + f.disabled.Store(v) + f.ready.Store(true) +} + +func (f *mockFileCache) SetReady(v bool) { + f.ready.Store(v) +} diff --git a/core/artwork/disc.go b/core/artwork/disc.go deleted file mode 100644 index a050f6685..000000000 --- a/core/artwork/disc.go +++ /dev/null @@ -1,281 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "io" - "path" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/slice" -) - -// discArtworkReader resolves disc-level artwork from a library's folder images -// and embedded tags. It is used by the serving path's provisional disc read-through. -type discArtworkReader struct { - album model.Album - discNumber int - imgFiles []string // library-relative, forward-slash, no leading slash - discFoldersRel map[string]bool // library-relative folder paths - isMultiFolder bool - firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs - lib libraryView - // Newest ImagesUpdatedAt across the album's and this disc's folders: an image can be - // replaced without the album row changing, so this is what makes a cache key notice it. - imagesUpdatedAt time.Time -} - -// cacheTime is the disc image's validity stamp: any of these moving means the selection may -// have changed. -func (d *discArtworkReader) cacheTime() time.Time { - return utils.TimeNewest(d.album.UpdatedAt, d.album.ImportedAt, d.imagesUpdatedAt) -} - -func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.ArtworkID) (*discArtworkReader, error) { - albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID) - if err != nil { - return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err) - } - - al, err := ds.Album().Get(ctx, albumID) - if err != nil { - return nil, err - } - - _, imgFiles, albumImagesAt, err := loadAlbumFoldersPaths(ctx, ds, *al) - if err != nil { - return nil, err - } - - var imagesUpdatedAt time.Time - if albumImagesAt != nil { - imagesUpdatedAt = *albumImagesAt - } - - // Query mediafiles for this album + disc to find folder associations and first track - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Sort: "track_number", - Order: "ASC", - Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber}, - }) - if err != nil { - return nil, err - } - - lib, err := loadLibraryView(ctx, ds, al.LibraryID) - if err != nil { - return nil, err - } - - // Build disc folder set and find first track. mf.Path is already library-relative. - var firstTrackRel string - for _, mf := range mfs { - if mf.Path != "" { - firstTrackRel = filepath.ToSlash(mf.Path) - break - } - } - folderIDs := slice.Unique(slice.Map(mfs, func(mf model.MediaFile) string { return mf.FolderID })) - - // Resolve folder IDs to library-relative paths - discFoldersRel := make(map[string]bool) - if len(folderIDs) > 0 { - folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"folder.id": folderIDs}, - }) - if err != nil { - return nil, err - } - for _, f := range folders { - rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") - discFoldersRel[rel] = true - imagesUpdatedAt = utils.TimeNewest(imagesUpdatedAt, f.ImagesUpdatedAt) - } - } - - return &discArtworkReader{ - album: *al, - discNumber: discNumber, - imgFiles: imgFiles, - discFoldersRel: discFoldersRel, - isMultiFolder: len(al.FolderIDs) > 1, - firstTrackRel: firstTrackRel, - lib: lib, - imagesUpdatedAt: imagesUpdatedAt, - }, nil -} - -// discCandidate is one DiscArtPriority entry. skip is set when the entry maps to no source at -// all, so a chain walk can say why instead of leaving a configured entry unaccounted for. -type discCandidate struct { - pattern string - resolve func() (resolution, bool) - skip string -} - -func (d *discArtworkReader) discCandidates(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []discCandidate { - folder := func(sf sourceFunc) func() (resolution, bool) { - return func() (resolution, bool) { return resolveFolderSource(d.lib, sf) } - } - var cc []discCandidate - for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { - pattern = strings.TrimSpace(pattern) - if pattern == "" { - continue - } - c := discCandidate{pattern: pattern} - switch { - case pattern == "embedded": - c.resolve = func() (resolution, bool) { - return resolveEmbedded(ctx, d.lib, ffmpeg, d.firstTrackRel) - } - case pattern == externalCandidate: - c.skip = "external sources are not supported for disc artwork" - case pattern == "discsubtitle": - subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]) - if subtitle == "" { - c.skip = "disc has no subtitle" - } else { - c.resolve = folder(d.fromDiscSubtitle(ctx, subtitle)) - } - case len(d.imgFiles) == 0: - c.skip = "no images in album folder" - default: - c.resolve = folder(d.fromExternalFile(ctx, pattern)) - } - cc = append(cc, c) - } - return cc -} - -// selectImage walks the DiscArtPriority entries and returns the first that yields an image. -// chain records the walk; the serving path passes an untraced one and pays nothing for it. -func (d *discArtworkReader) selectImage(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string, - chain *chainState) (resolution, error) { - for _, c := range d.discCandidates(ctx, ffmpeg, priority) { - if err := ctx.Err(); err != nil { - return resolution{}, err - } - if c.skip != "" { - chain.record(c.pattern, OutcomeSkipped, c.skip) - continue - } - start := time.Now() - res, ok := c.resolve() - log.Trace(ctx, "Artwork: Tried a disc artwork candidate", "albumID", d.album.ID, - "disc", d.discNumber, "pattern", c.pattern, "hit", ok, "path", res.sourcePath, - "elapsed", time.Since(start)) - if res, ok = chain.try(c.pattern, res, ok); ok { - return res, nil - } - } - return chain.exhausted(), nil -} - -// fromDiscSubtitle returns a sourceFunc that matches image files whose stem -// (filename without extension) equals the disc subtitle (case-insensitive). -func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc { - return func() (io.ReadCloser, string, error) { - for _, file := range d.imgFiles { - stem := utils.BaseName(file) - if !strings.EqualFold(stem, subtitle) { - continue - } - f, err := d.lib.FS.Open(file) - if err != nil { - log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err) - continue - } - return f, file, nil - } - return nil, "", fmt.Errorf("disc %d: no image file matching subtitle %q", d.discNumber, subtitle) - } -} - -// filepath.Match's '\' escape is excluded on purpose: treating it as a metachar -// would misalign the literal-prefix extraction in extractDiscNumber. -const globMetaChars = "*?[" - -// extractDiscNumber parses the disc number from a filename matched by a filepath.Match-style -// glob. Caller must lowercase both args and have already verified the match. -func extractDiscNumber(pattern, filename string) (int, bool) { - metaIdx := strings.IndexAny(pattern, globMetaChars) - if metaIdx < 0 { - return 0, false - } - prefix := pattern[:metaIdx] - if !strings.HasPrefix(filename, prefix) { - return 0, false - } - - start := len(prefix) - end := start - for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' { - end++ - } - if end == start { - return 0, false - } - num, err := strconv.Atoi(filename[start:end]) - if err != nil { - return 0, false - } - return num, true -} - -// fromExternalFile matches image files against a (lowercase) glob pattern. A numbered -// filename whose number equals the target disc wins over any unnumbered candidate. -func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc { - isLiteral := !strings.ContainsAny(pattern, globMetaChars) - return func() (io.ReadCloser, string, error) { - var fallbacks []string - for _, file := range d.imgFiles { - name := strings.ToLower(path.Base(file)) - match, err := filepath.Match(pattern, name) - if err != nil { - log.Warn(ctx, "Artwork: Error matching disc art file to pattern", "pattern", pattern, "file", file) - continue - } - if !match { - continue - } - - if !isLiteral { - if num, hasNum := extractDiscNumber(pattern, name); hasNum { - if num != d.discNumber { - continue - } - f, err := d.lib.FS.Open(file) - if err != nil { - log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err) - continue - } - return f, file, nil - } - } - - if d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] { - continue - } - fallbacks = append(fallbacks, file) - } - - for _, file := range fallbacks { - f, err := d.lib.FS.Open(file) - if err != nil { - log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err) - continue - } - return f, file, nil - } - return nil, "", fmt.Errorf("disc %d: pattern '%s' not matched by files", d.discNumber, pattern) - } -} diff --git a/core/artwork/dominant/dominant.go b/core/artwork/dominant/dominant.go deleted file mode 100644 index 2bbda8ae9..000000000 --- a/core/artwork/dominant/dominant.go +++ /dev/null @@ -1,127 +0,0 @@ -// Package dominant extracts an image's dominant colour, for use as a flat placeholder while the -// real artwork loads. -package dominant - -import ( - "fmt" - "image" - "math" - "sort" -) - -const ( - // 4 bits per channel: coarse enough that near-identical pixels land together, fine enough that - // distinct colours stay apart. - bits = 4 - nBins = 1 << (3 * bits) - // Only the heaviest bins can win, and merging is O(n^2) over whatever survives. - maxBins = 64 - // Oklab distance below which two bins are the same colour to the eye. Merging matters because a - // gradient splits across adjacent bins and would otherwise lose to a smaller flat region. - mergeDist = 0.10 -) - -type bin struct { - r, g, b float64 - n float64 -} - -// Color returns the dominant colour as "#rrggbb", or "" when the image has no pixels. It reports -// presence, not salience: a mostly white sleeve returns white. -func Color(img image.Image) string { - var bins [nBins]bin - total := 0 - eachPixel(img, func(r, g, b uint8) { - i := int(r>>(8-bits))<<(2*bits) | int(g>>(8-bits))<>(8-bits)) - bins[i].r += float64(r) - bins[i].g += float64(g) - bins[i].b += float64(b) - bins[i].n++ - total++ - }) - if total == 0 { - return "" - } - - used := make([]bin, 0, 32) - for i := range bins { - if bins[i].n > 0 { - used = append(used, bins[i]) - } - } - sort.Slice(used, func(i, j int) bool { return used[i].n > used[j].n }) - if len(used) > maxBins { - used = used[:maxBins] - } - - merged := make([]bin, 0, len(used)) - for _, b := range used { - if i := nearest(merged, b); i >= 0 { - merged[i].r += b.r - merged[i].g += b.g - merged[i].b += b.b - merged[i].n += b.n - continue - } - merged = append(merged, b) - } - - best := merged[0] - for _, m := range merged[1:] { - if m.n > best.n { - best = m - } - } - return fmt.Sprintf("#%02x%02x%02x", - uint8(best.r/best.n+0.5), uint8(best.g/best.n+0.5), uint8(best.b/best.n+0.5)) -} - -func nearest(merged []bin, b bin) int { - bl, ba, bb := oklab(b.r/b.n, b.g/b.n, b.b/b.n) - for i, m := range merged { - ml, ma, mb := oklab(m.r/m.n, m.g/m.n, m.b/m.n) - if math.Sqrt((bl-ml)*(bl-ml)+(ba-ma)*(ba-ma)+(bb-mb)*(bb-mb)) < mergeDist { - return i - } - } - return -1 -} - -// eachPixel walks the image, taking the NRGBA fast path the artwork pipeline always hits: both hash -// encoders already read the shared thumbnail in that form. -func eachPixel(img image.Image, fn func(r, g, b uint8)) { - if p, ok := img.(*image.NRGBA); ok { - for y := range p.Rect.Dy() { - row := p.Pix[y*p.Stride : y*p.Stride+p.Rect.Dx()*4] - for x := 0; x < len(row); x += 4 { - fn(row[x], row[x+1], row[x+2]) - } - } - return - } - b := img.Bounds() - for y := b.Min.Y; y < b.Max.Y; y++ { - for x := b.Min.X; x < b.Max.X; x++ { - r, g, bl, _ := img.At(x, y).RGBA() - fn(uint8(r>>8), uint8(g>>8), uint8(bl>>8)) - } - } -} - -func srgbToLinear(v float64) float64 { - v /= 255 - if v <= 0.04045 { - return v / 12.92 - } - return math.Pow((v+0.055)/1.055, 2.4) -} - -func oklab(r, g, b float64) (float64, float64, float64) { - lr, lg, lb := srgbToLinear(r), srgbToLinear(g), srgbToLinear(b) - l := math.Cbrt(0.4122214708*lr + 0.5363325363*lg + 0.0514459929*lb) - m := math.Cbrt(0.2119034982*lr + 0.6806995451*lg + 0.1073969566*lb) - s := math.Cbrt(0.0883024619*lr + 0.2817188376*lg + 0.6299787005*lb) - return 0.2104542553*l + 0.7936177850*m - 0.0040720468*s, - 1.9779984951*l - 2.4285922050*m + 0.4505937099*s, - 0.0259040371*l + 0.7827717662*m - 0.8086757660*s -} diff --git a/core/artwork/dominant/dominant_suite_test.go b/core/artwork/dominant/dominant_suite_test.go deleted file mode 100644 index 56dbd6209..000000000 --- a/core/artwork/dominant/dominant_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package dominant_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestDominant(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Dominant Suite") -} diff --git a/core/artwork/dominant/dominant_test.go b/core/artwork/dominant/dominant_test.go deleted file mode 100644 index da7fed09c..000000000 --- a/core/artwork/dominant/dominant_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package dominant_test - -import ( - "image" - "image/color" - - "github.com/navidrome/navidrome/core/artwork/dominant" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// fill paints rect with c onto img. -func fill(img *image.NRGBA, r image.Rectangle, c color.NRGBA) { - for y := r.Min.Y; y < r.Max.Y; y++ { - for x := r.Min.X; x < r.Max.X; x++ { - img.SetNRGBA(x, y, c) - } - } -} - -func newImg(w, h int, c color.NRGBA) *image.NRGBA { - img := image.NewNRGBA(image.Rect(0, 0, w, h)) - fill(img, img.Bounds(), c) - return img -} - -var _ = Describe("Color", func() { - It("returns a solid image's own colour", func() { - Expect(dominant.Color(newImg(20, 20, color.NRGBA{0x33, 0x66, 0x99, 255}))).To(Equal("#336699")) - }) - - It("returns empty for an image with no pixels", func() { - Expect(dominant.Color(image.NewNRGBA(image.Rect(0, 0, 0, 0)))).To(Equal("")) - }) - - // Presence, not salience: this is a placeholder, so the large field wins even though the small - // patch is the more interesting colour. - It("picks the largest area, not the most vivid one", func() { - img := newImg(20, 20, color.NRGBA{0xfa, 0xfa, 0xfa, 255}) - fill(img, image.Rect(0, 0, 4, 4), color.NRGBA{0xff, 0x00, 0x00, 255}) - Expect(dominant.Color(img)).To(Equal("#fafafa")) - }) - - It("reports a near-black cover as near-black", func() { - img := newImg(20, 20, color.NRGBA{0x05, 0x05, 0x05, 255}) - fill(img, image.Rect(0, 0, 5, 5), color.NRGBA{0x00, 0xff, 0x00, 255}) - Expect(dominant.Color(img)).To(Equal("#050505")) - }) - - // A gradient splits across many quantisation bins. Without merging, each slice is smaller than - // the flat block and the block would win despite covering far less of the image. - It("merges a gradient's bins so it beats a smaller flat block", func() { - img := image.NewNRGBA(image.Rect(0, 0, 40, 40)) - for y := range 40 { - for x := range 40 { - // 30 columns of blue gradient == 75% of the image - if x < 30 { - img.SetNRGBA(x, y, color.NRGBA{0x10, 0x20, uint8(0xa0 + x), 255}) - } else { - img.SetNRGBA(x, y, color.NRGBA{0xff, 0xcc, 0x00, 255}) - } - } - } - got := dominant.Color(img) - Expect(got).To(HavePrefix("#1020"), "expected the blue gradient, got "+got) - }) - - It("is deterministic", func() { - img := image.NewNRGBA(image.Rect(0, 0, 30, 30)) - for y := range 30 { - for x := range 30 { - img.SetNRGBA(x, y, color.NRGBA{uint8(x * 7), uint8(y * 5), uint8(x + y), 255}) - } - } - first := dominant.Color(img) - for range 5 { - Expect(dominant.Color(img)).To(Equal(first)) - } - }) - - It("handles images that are not NRGBA", func() { - src := newImg(10, 10, color.NRGBA{0x20, 0x40, 0x60, 255}) - rgba := image.NewRGBA(src.Bounds()) - for y := range 10 { - for x := range 10 { - rgba.Set(x, y, src.At(x, y)) - } - } - Expect(dominant.Color(rgba)).To(Equal("#204060")) - }) -}) diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go deleted file mode 100644 index 8cfa54113..000000000 --- a/core/artwork/e2e/acquire_serve_test.go +++ /dev/null @@ -1,349 +0,0 @@ -package e2e - -import ( - "context" - "encoding/base64" - "errors" - "io" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Covers the enqueue → drain → serve chain; per-source resolution rules live in the unit suites. -var _ = Describe("Acquisition → serve loop", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - artRepo *tests.MockArtworkRepo - queueRepo *tests.MockArtworkQueueRepo - albumRepo *tests.MockAlbumRepo - artistRepo *tests.MockArtistRepo - mfRepo *tests.MockMediaFileRepo - plRepo *tests.MockPlaylistRepo - radioRepo *tests.MockedRadioRepo - folderRepo *fakeFolderRepo - libRepo *tests.MockLibraryRepo - store *artwork.ImageStore - svc artwork.Artwork - worker *artwork.Worker - coverBytes []byte - ) - - itemFound := func(kind model.Kind, id string) func() bool { - return func() bool { - ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary) - return err == nil && ia.Hash != "" - } - } - itemAbsent := func(kind model.Kind, id string) func() bool { - return func() bool { - ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary) - return err == nil && ia.Hash == "" - } - } - // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. - bump := func(kind, id string) { - GinkgoHelper() - Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{ - ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBump, - })).To(Succeed()) - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - coverBytes = readFixture(coverFixture) - - conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) - conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) - conf.Server.CoverArtPriority = "cover.jpg" - conf.Server.ArtistArtPriority = "artist.png" // keeps artist resolution offline - conf.Server.EnableMediaFileCoverArt = true - conf.Server.DevArtworkWorkerConcurrency = 1 - - folderRepo = &fakeFolderRepo{} - libRepo = &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: repoRoot}}) - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - albumRepo = tests.CreateMockAlbumRepo() - artistRepo = tests.CreateMockArtistRepo() - mfRepo = tests.CreateMockMediaFileRepo() - plRepo = tests.CreateMockPlaylistRepo() - radioRepo = tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{} - ds = &tests.MockDataStore{ - MockedArtwork: artRepo, - MockedArtworkQueue: queueRepo, - MockedAlbum: albumRepo, - MockedArtist: artistRepo, - MockedMediaFile: mfRepo, - MockedPlaylist: plRepo, - MockedRadio: radioRepo, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - ffm := tests.NewMockFFmpeg("") - store = artwork.NewImageStore(GinkgoT().TempDir()) - // size=0 requests stream originals, so this reader is never called (serving_test covers resizing). - imgCache := cache.NewFileCache("ArtworkPipelineE2E", "100MB", "images", 0, - func(context.Context, cache.Item) (io.Reader, error) { - return nil, errors.New("resize not exercised in e2e") - }) - Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) - - svc = artwork.NewArtwork(ds, imgCache, store, ffm) - worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) - }) - - seedFolderAlbum := func(albumID string) { - folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) - } - - It("acquires and serves a cover whose format has no registered decoder (#5950)", func() { - libDir := GinkgoT().TempDir() - Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed()) - - conf.Server.CoverArtPriority = "cover.*" - libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}}) - folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}} - albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) - - bump("al", "al1") - runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Placeholder).To(BeFalse()) - Expect(readAll(img)).To(Equal(jxlFixture)) - }) - - It("acquires album folder art and serves the exact bytes under its hash", func() { - seedFolderAlbum("al1") - bump("al", "al1") - runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Hash).To(Equal(ia.Hash)) - Expect(img.Placeholder).To(BeFalse()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("acquires an artist's uploaded image and serves it", func() { - name := writeUpload(consts.EntityArtist, "artist-e2e.png", artistPngFixture) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: name}}) - bump("ar", "ar1") - runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("upload")) - - img, err := svc.Get(ctx, model.MustParseArtworkID("ar-ar1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Hash).To(Equal(ia.Hash)) - Expect(readAll(img)).To(Equal(readFixture(artistPngFixture))) - }) - - It("generates a playlist grid from its tracks' album art and serves it from the store", func() { - seedFolderAlbum("al1") - plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"al1"}} - bump("pl", "pl1") - runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("generated")) - - img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Hash).To(Equal(ia.Hash)) - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(art.Mime).To(Equal("image/png")) - Expect(len(readAll(img))).To(BeNumerically(">", 0)) - }) - - It("acquires a radio station's uploaded image and serves it", func() { - name := writeUpload(consts.EntityRadio, "radio-e2e.jpg", coverFixture) - radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name} - bump("ra", "ra1") - runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("upload")) - - img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Hash).To(Equal(ia.Hash)) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("serves an unresolved track provisionally, then upgrades to the worker's state row", func() { - mfRepo.SetData(model.MediaFiles{{ - ID: "mf1", AlbumID: "al1", HasCoverArt: true, LibraryID: 0, Path: mp3Fixture, - }}) - - provisional, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(provisional.Placeholder).To(BeFalse()) - Expect(provisional.Hash).ToNot(BeEmpty()) - provisionalBytes := readAll(provisional) - Expect(len(provisionalBytes)).To(BeNumerically(">", 0)) - - _, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row") - - // The provisional read enqueued a Bump; drain it. - runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1")) - ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("embedded")) - Expect(ia.Hash).To(Equal(provisional.Hash)) - - resolved, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(resolved.Hash).To(Equal(ia.Hash)) - Expect(readAll(resolved)).To(Equal(provisionalBytes)) - }) - - It("stores dimensions, mime and a real blurhash alongside the acquired bytes", func() { - seedFolderAlbum("al1") - bump("al", "al1") - runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(art.Mime).To(Equal("image/jpeg")) - Expect(art.Width).To(BeNumerically(">", 0)) - Expect(art.Height).To(BeNumerically(">", 0)) - Expect(art.SizeBytes).To(BeNumerically("==", len(coverBytes))) - // Never a synthesized value: both hashes are encoded from the real pixels. - Expect(art.BlurHash).ToNot(BeEmpty()) - Expect(art.ThumbHash).ToNot(BeEmpty()) - raw, err := base64.StdEncoding.DecodeString(art.ThumbHash) - Expect(err).ToNot(HaveOccurred()) - Expect(len(raw)).To(BeNumerically(">=", 5)) - }) - - It("acquires GIF artwork, whose decoder only core/artwork's blank import registers", func() { - writeUploadedImage(consts.EntityRadio, "station.gif", gifFixture) - radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: "station.gif"} - bump("ra", "ra1") - runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(art.Mime).To(Equal("image/gif")) - Expect(art.Width).To(BeNumerically("==", 4)) - }) - - It("deduplicates byte-identical art across entities onto one image row", func() { - folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{ - {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}, - {ID: "al2", Name: "Same Cover", FolderIDs: []string{"f1"}, LibraryID: 0}, - }) - bump("al", "al1") - bump("al", "al2") - runWorkerUntil(ctx, worker, func() bool { - return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")() - }) - - ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash") - Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes)) - }) - - It("stops serving a file-backed image once its source file changes underneath", func() { - name := writeUpload(consts.EntityRadio, "radio-stale.jpg", coverFixture) - radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name} - bump("ra", "ra1") - runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - staleHash := ia.Hash - - path := model.UploadedImagePath(consts.EntityRadio, name) - Expect(os.WriteFile(path, readFixture(artistPngFixture), 0o600)).To(Succeed()) - newer := time.Now().Add(2 * time.Second) - Expect(os.Chtimes(path, newer, newer)).To(Succeed()) - - // The mtime no longer matches the state row, so the stale bytes are not served. - _, err = svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) - Expect(err).To(MatchError(artwork.ErrUnavailable)) - - // That failed read enqueued a re-resolution. - runWorkerUntil(ctx, worker, func() bool { - cur, gerr := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) - return gerr == nil && cur.Hash != "" && cur.Hash != staleHash - }) - img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(readFixture(artistPngFixture))) - }) - - It("records an absent state for an entity with no art and reports it unavailable", func() { - albumRepo.SetData(model.Albums{{ID: "alx", Name: "Artless", LibraryID: 0}}) - bump("al", "alx") - runWorkerUntil(ctx, worker, itemAbsent(model.KindAlbumArtwork, "alx")) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-alx"), 0, false) - Expect(err).To(MatchError(artwork.ErrUnavailable)) - - img, err := svc.GetOrPlaceholder(ctx, "al-alx", 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Placeholder).To(BeTrue()) - }) -}) - -func mustGet(img *artwork.Image, err error) *artwork.Image { - GinkgoHelper() - Expect(err).ToNot(HaveOccurred()) - return img -} - -// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking -// jxlFixture is a JPEG XL bare codestream header: a real image format, with no stdlib decoder. -var jxlFixture = []byte{0xff, 0x0a, 0x00, 0x10, 0x00} - -// the production import the spec above guards. -var gifFixture = []byte{ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00, - 0x00, 0x2e, 0x86, 0xc1, 0xf4, 0xd0, 0x3f, 0x2c, 0x00, 0x00, 0x00, 0x00, - 0x04, 0x00, 0x04, 0x00, 0x00, 0x02, 0x05, 0x44, 0x7c, 0x67, 0xb8, 0x05, - 0x00, 0x3b, -} diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index 7b5e7b4a3..e765e1b1b 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -1,4 +1,4 @@ -package e2e +package artworke2e_test import ( "testing/fstest" @@ -9,11 +9,14 @@ import ( . "github.com/onsi/gomega" ) -// The in-memory library FS cannot satisfy the os.Open(SourcePath) used to serve folder art, so -// folder scenarios assert on the worker's state row (Source + SourcePath) instead of the bytes. +const ( + defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" + defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" +) + var _ = Describe("Album artwork resolution", func() { BeforeEach(func() { - setupResolutionHarness() + setupHarness() }) When("an album has a single folder with cover.jpg at the album root", func() { @@ -25,10 +28,12 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": smallPNG("album-root"), + "Artist/Album/cover.jpg": imageFile("album-root"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) }) }) @@ -50,16 +55,16 @@ var _ = Describe("Album artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Artist/Album/cover.jpg": smallPNG("album-root"), - "Artist/Album/CD1/cover.jpg": smallPNG("disc1"), - "Artist/Album/CD2/cover.jpg": smallPNG("disc2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/CD1/cover.jpg": imageFile("disc1"), + "Artist/Album/CD2/cover.jpg": imageFile("disc2"), }) scan() al := firstAlbum() Expect(al.FolderIDs).To(HaveLen(2), - "sanity check: the two disc subfolders should form one multi-disc album") - expectAlbumFolderCover(al, "Artist/Album/cover.jpg") + "sanity check: scanner should treat the two disc subfolders as one multi-disc album") + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) }) }) @@ -81,12 +86,14 @@ var _ = Describe("Album artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Artist/Album/folder.jpg": smallPNG("album-root"), - "Artist/Album/CD1/folder.jpg": smallPNG("disc1"), - "Artist/Album/CD2/folder.jpg": smallPNG("disc2"), + "Artist/Album/folder.jpg": imageFile("album-root"), + "Artist/Album/CD1/folder.jpg": imageFile("disc1"), + "Artist/Album/CD2/folder.jpg": imageFile("disc2"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) }) }) @@ -102,10 +109,12 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": smallPNG("album-root"), + "Artist/Album/cover.jpg": imageFile("album-root"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) }) }) @@ -124,12 +133,14 @@ var _ = Describe("Album artwork resolution", func() { setLayout(fstest.MapFS{ "Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), "Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Album/cover.jpg": smallPNG("album-root"), - "Album/CD1/folder.jpg": smallPNG("disc1"), - "Album/CD2/folder.jpg": smallPNG("disc2"), + "Album/cover.jpg": imageFile("album-root"), + "Album/CD1/folder.jpg": imageFile("disc1"), + "Album/CD2/folder.jpg": imageFile("disc2"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Album/cover.jpg") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) }) }) @@ -142,14 +153,14 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), - "Artist/Album/cover.jpg": smallPNG("external"), + "Artist/Album/cover.jpg": imageFile("external"), }) scan() + // Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream. replaceWithRealMP3("Artist/Album/01 - Track.mp3") - ia := acquire(model.KindAlbumArtwork, firstAlbum().ID) - Expect(ia.Source).To(Equal("embedded")) - Expect(storedBytes(ia)).To(Equal(embeddedArtBytes)) + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) }) }) @@ -165,9 +176,8 @@ var _ = Describe("Album artwork resolution", func() { scan() replaceWithRealMP3("Artist/Album/01 - Track.mp3") - ia := acquire(model.KindAlbumArtwork, firstAlbum().ID) - Expect(ia.Source).To(Equal("embedded")) - Expect(storedBytes(ia)).To(Equal(embeddedArtBytes)) + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) }) }) @@ -180,10 +190,12 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "cover.*, folder.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/Cover.JPG": smallPNG("case-insensitive"), + "Artist/Album/Cover.JPG": imageFile("case-insensitive"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/Cover.JPG") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive"))) }) }) @@ -197,25 +209,30 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": smallPNG("primary"), - "Artist/Album/cover.1.jpg": smallPNG("secondary"), + "Artist/Album/cover.jpg": imageFile("primary"), + "Artist/Album/cover.1.jpg": imageFile("secondary"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) }) }) When("the album has no cover and CoverArtPriority lists only file patterns", func() { // Artist/ // └── Album/ - // └── 01 - Track.mp3 (no image files — settles absent) - It("settles absent", func() { + // └── 01 - Track.mp3 (no image files — returns ErrUnavailable) + It("returns ErrUnavailable", func() { conf.Server.CoverArtPriority = "cover.*, folder.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), }) scan() - expectAlbumAbsent(firstAlbum()) + + al := firstAlbum() + _, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt)) + Expect(err).To(HaveOccurred()) }) }) @@ -231,10 +248,12 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/folder.jpg": smallPNG("folder"), + "Artist/Album/folder.jpg": imageFile("folder"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) }) }) @@ -247,10 +266,12 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/front.jpg": smallPNG("front"), + "Artist/Album/front.jpg": imageFile("front"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/front.jpg") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front"))) }) }) @@ -265,12 +286,14 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": smallPNG("cover"), - "Artist/Album/folder.jpg": smallPNG("folder"), - "Artist/Album/front.jpg": smallPNG("front"), + "Artist/Album/cover.jpg": imageFile("cover"), + "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/front.jpg": imageFile("front"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) }) }) @@ -284,11 +307,13 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/folder.jpg": smallPNG("folder"), - "Artist/Album/front.jpg": smallPNG("front"), + "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/front.jpg": imageFile("front"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) }) }) @@ -303,12 +328,14 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.2.jpg": smallPNG("second"), - "Artist/Album/cover.jpg": smallPNG("primary"), - "Artist/Album/cover.1.jpg": smallPNG("first"), + "Artist/Album/cover.2.jpg": imageFile("second"), + "Artist/Album/cover.jpg": imageFile("primary"), + "Artist/Album/cover.1.jpg": imageFile("first"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) }) }) @@ -321,134 +348,12 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "bogus.*, cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": smallPNG("cover"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") - }) - }) - - // 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": smallPNG("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": smallPNG("album-b"), + "Artist/Album/cover.jpg": imageFile("cover"), }) scan() - // Album B first: the acquire in expectAlbumAbsent would settle Album B too. - expectAlbumFolderCover(albumByName("Album B"), "Artist/Album B/cover.jpg") - expectAlbumAbsent(albumByName("Album A")) - }) - }) - - 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": smallPNG("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": smallPNG("album-b"), - }) - scan() - - alA := albumByName("Album A") - Expect(alA.FolderIDs).To(HaveLen(2), - "sanity check: the two sibling folders should form one spread album") - expectAlbumAbsent(alA) - }) - }) - - // albumRootParent refuses the library root as an album root (parent.ParentID == ""). - When("a multi-disc album sits directly at the library root with a cover.jpg beside it", func() { - // (library root) - // ├── cover.jpg ← must NOT be adopted - // ├── CD1/ - // │ └── 01 - Track.mp3 - // └── CD2/ - // └── 01 - Track.mp3 - It("does not adopt the library-root image as album art", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "cover.jpg": smallPNG("library-root"), - "CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}), - "CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"album": "Rootless", "disc": "2"}), - }) - scan() - expectAlbumAbsent(firstAlbum()) - }) - }) - - // The shallower artist-folder cover.jpg would win the basename tie, but albumRootParent skips - // the parent folder for a single-folder album that has images of its own. - When("a single-folder album has its own cover.jpg and the artist folder has one too", func() { - // Artist/ - // ├── cover.jpg ← shallower, but must NOT win - // └── Album/ - // ├── 01 - Track.mp3 - // └── cover.jpg ← should win - It("prefers the album's own cover over the shallower artist-folder cover", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/cover.jpg": smallPNG("artist-image"), - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/Album/cover.jpg": smallPNG("album-own"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.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 (other-album audio: rejects the artist folder as a root) - It("prefers the album's own art over the artist image", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/cover.jpg": smallPNG("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": smallPNG("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: the two sibling folders should form one spread album") - expectAlbumFolderCover(alA, "Artist/Album A/front.jpg") + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) }) }) @@ -461,10 +366,12 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "embedded, cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": smallPNG("cover"), + "Artist/Album/cover.jpg": imageFile("cover"), }) scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) }) }) }) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go index 38c28a9b2..d959b1d60 100644 --- a/core/artwork/e2e/artist_test.go +++ b/core/artwork/e2e/artist_test.go @@ -1,4 +1,4 @@ -package e2e +package artworke2e_test import ( "os" @@ -8,7 +8,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -17,11 +16,9 @@ import ( // Doc reference: // https://www.navidrome.org/docs/usage/library/artwork/#artists // Default ArtistArtPriority is "artist.*, album/artist.*, external". -// Library-folder images are file-backed (asserted on the worker state row); uploaded and -// image-folder images are real files on disk (asserted byte-for-byte). var _ = Describe("Artist artwork resolution", func() { BeforeEach(func() { - setupResolutionHarness() + setupHarness() }) When("the artist folder contains an artist.jpg", func() { @@ -33,10 +30,13 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": smallPNG("artist-folder"), + "Artist/artist.jpg": imageFile("artist-folder"), }) scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) }) }) @@ -49,10 +49,13 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/Album/artist.jpg": smallPNG("album-artist"), + "Artist/Album/artist.jpg": imageFile("album-artist"), }) scan() - expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg") + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) }) }) @@ -66,116 +69,14 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": smallPNG("artist-folder"), - "Artist/Album/artist.jpg": smallPNG("album-artist"), + "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/Album/artist.jpg": imageFile("album-artist"), }) scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") - }) - }) - When("ArtistArtPriority has no album/ fallback", func() { - // Artist/ - // ├── artist.jpg ← must resolve via the artist folder itself - // └── Album/ - // └── 01 - Track.mp3 - It("still resolves the artist folder and returns artist.*", func() { - conf.Server.ArtistArtPriority = "artist.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": smallPNG("artist-folder"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") - }) - }) - - When("the artist's only album folder has no images of its own", func() { - // Artist/ - // ├── backdrop1.jpg - // ├── folder.jpg ← matched by folder.* - // ├── logo.png - // └── Album/ - // ├── 01 - Track.mp3 - // └── 02 - Track.mp3 - It("resolves the artist folder, not the library root", func() { - conf.Server.ArtistArtPriority = "folder.*, artist.*, album/artist.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), - "Artist/Album/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), - "Artist/backdrop1.jpg": smallPNG("backdrop"), - "Artist/folder.jpg": smallPNG("artist-folder"), - "Artist/logo.png": smallPNG("logo"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/folder.jpg") - }) - }) - - When("the artist's only album has its tracks in disc subfolders", func() { - // Artist/ - // ├── artist.jpg ← wins (artist.* before album/artist.*) - // └── Album/ - // ├── artist.jpg - // ├── CD1/01 - Track.mp3 - // └── CD2/02 - Track.mp3 - It("prefers the artist-folder image over the album-folder one", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" - setLayout(fstest.MapFS{ - "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), - "Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), - "Artist/artist.jpg": smallPNG("artist-folder"), - "Artist/Album/artist.jpg": smallPNG("album-artist"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") - }) - }) - - When("one album has disc subfolders and another sits at artist level", func() { - // Artist/ - // ├── artist.jpg ← wins - // ├── Album1/ - // │ ├── artist.jpg - // │ ├── CD1/01 - Track.mp3 - // │ └── CD2/02 - Track.mp3 - // └── Album2/03 - Track.mp3 - It("prefers the artist-folder image over the album-folder one", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" - setLayout(fstest.MapFS{ - "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), - "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), - "Artist/Album2/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), - "Artist/artist.jpg": smallPNG("artist-folder"), - "Artist/Album1/artist.jpg": smallPNG("album-artist"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") - }) - }) - - When("every album of the artist has its tracks in disc subfolders", func() { - // Artist/ - // ├── artist.jpg ← wins - // ├── Album1/ - // │ ├── artist.jpg - // │ ├── CD1/01 - Track.mp3 - // │ └── CD2/02 - Track.mp3 - // └── Album2/ - // ├── CD1/03 - Track.mp3 - // └── CD2/04 - Track.mp3 - It("prefers the artist-folder image over the album-folder one", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" - setLayout(fstest.MapFS{ - "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), - "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), - "Artist/Album2/CD1/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), - "Artist/Album2/CD2/04 - Track.mp3": trackFile(4, "Track 4", map[string]any{"albumartist": "Artist", "album": "Album2"}), - "Artist/artist.jpg": smallPNG("artist-folder"), - "Artist/Album1/artist.jpg": smallPNG("album-artist"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) }) }) @@ -193,19 +94,18 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": smallPNG("artist-folder"), + "Artist/artist.jpg": imageFile("artist-folder"), }) scan() ar := soleArtist() uploaded := ar.ID + "_upload.jpg" - writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded")) + writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded")) ar.UploadedImage = uploaded - Expect(rds.Artist().Put(rctx, &ar)).To(Succeed()) + Expect(ds.Artist(ctx).Put(&ar)).To(Succeed()) - ia := acquire(model.KindArtistArtwork, ar.ID) - Expect(ia.Source).To(Equal("upload")) - Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("artist-uploaded"))) + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded"))) }) }) @@ -218,36 +118,13 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/Album/artist.jpg": smallPNG("album-artist"), + "Artist/Album/artist.jpg": imageFile("album-artist"), }) scan() - expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg") - }) - }) - - // resolveArtist only samples albums where this artist is the SOLE album artist, so a - // collaboration or compilation never donates its images as the artist's own. - When("the artist's only album is credited to two album artists", func() { - // Artist/ - // └── Collab Album/ (album artists: "Artist" + a collaborator) - // ├── 01 - Track.mp3 - // └── artist.jpg ← must NOT become the artist image - It("ignores the album's images and settles absent", func() { - conf.Server.ArtistArtPriority = "album/artist.*" - // " / " is a default artists split separator, so this single tag yields two album artists. - setLayout(fstest.MapFS{ - "Artist/Collab Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist / Collaborator"}), - "Artist/Collab Album/artist.jpg": smallPNG("collab-artist"), - }) - scan() - Expect(firstAlbum().Participants[model.RoleAlbumArtist]).To(HaveLen(2), - "sanity check: the album must be credited to two album artists") ar := soleArtist() - ia := acquire(model.KindArtistArtwork, ar.ID) - Expect(ia.Hash).To(BeEmpty()) - Expect(serveErr(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))). - To(MatchError(artwork.ErrUnavailable)) + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) }) }) @@ -260,7 +137,7 @@ var _ = Describe("Artist artwork resolution", func() { // └── 01 - Track.mp3 (no artist.* present in library) It("returns the image from the configured artist image folder", func() { imgFolder := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), pngBytes("image-folder"), 0o600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed()) conf.Server.ArtistImageFolder = imgFolder conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*" @@ -270,16 +147,15 @@ var _ = Describe("Artist artwork resolution", func() { scan() ar := soleArtist() - ia := acquire(model.KindArtistArtwork, ar.ID) - Expect(ia.Source).To(Equal("folder")) - Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("image-folder"))) + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder"))) }) }) }) func soleArtist() model.Artist { GinkgoHelper() - artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{ + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"artist.name": "Artist"}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go index 5fdd5f27b..667079458 100644 --- a/core/artwork/e2e/disc_test.go +++ b/core/artwork/e2e/disc_test.go @@ -1,20 +1,18 @@ -package e2e +package artworke2e_test import ( "fmt" "testing/fstest" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// Disc art is a serve-time read through the library FS (no worker state row), so per-disc images -// are asserted byte-for-byte, while album-root covers are asserted on the state row. var _ = Describe("Disc artwork resolution", func() { BeforeEach(func() { - setupResolutionHarness() + setupHarness() }) When("the album is single-disc with a disc1.jpg in the only folder", func() { @@ -26,25 +24,32 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/disc1.jpg": smallPNG("disc1-image"), + "Artist/Album/disc1.jpg": imageFile("disc1-image"), }) scan() - expectDiscImage(firstAlbum(), 1, "disc1-image") + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image"))) }) }) When("the album has no per-disc image and no album cover", func() { // Artist/ // └── Album/ - // └── 01 - Track.mp3 (no disc or album art — nothing to serve) - It("reports the disc lookup as unavailable", func() { + // └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable) + It("returns ErrUnavailable for the disc lookup", func() { conf.Server.DiscArtPriority = "disc*.*, cd*.*" conf.Server.CoverArtPriority = "cover.*, folder.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), }) scan() - Expect(serveErr(discArtID(firstAlbum(), 1))).To(MatchError(artwork.ErrUnavailable)) + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + _, err := readArtworkOrErr(discID) + Expect(err).To(HaveOccurred()) }) }) @@ -58,10 +63,13 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": smallPNG("album-cover"), + "Artist/Album/cover.jpg": imageFile("album-cover"), }) scan() - expectDiscImage(firstAlbum(), 1, "album-cover") + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover"))) }) }) @@ -75,11 +83,14 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "disc*.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/disc1.jpg": smallPNG("disc-one"), - "Artist/Album/disc10.jpg": smallPNG("disc-ten"), + "Artist/Album/disc1.jpg": imageFile("disc-one"), + "Artist/Album/disc10.jpg": imageFile("disc-ten"), }) scan() - expectDiscImage(firstAlbum(), 1, "disc-one") + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one"))) }) }) @@ -97,11 +108,14 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), - "Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), }) scan() - expectDiscImage(firstAlbum(), 2, "disc-2") + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2"))) }) }) @@ -122,11 +136,14 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), - "Artist/Album/CD2/cd2.png": smallPNG("cd-2"), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/cd2.png": imageFile("cd-2"), }) scan() - expectDiscImage(firstAlbum(), 2, "cd-2") + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2"))) }) }) @@ -144,11 +161,14 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/cover.jpg": smallPNG("disc1-cover"), - "Artist/Album/CD2/cover.jpg": smallPNG("disc2-cover"), + "Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"), + "Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"), }) scan() - expectDiscImage(firstAlbum(), 1, "disc1-cover") + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover"))) }) }) @@ -168,15 +188,17 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), - "Artist/Album/CD2/cd2.png": smallPNG("cd-2"), - "Artist/Album/cover.jpg": smallPNG("album-cover"), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/cd2.png": imageFile("cd-2"), + "Artist/Album/cover.jpg": imageFile("album-cover"), }) scan() al := firstAlbum() for _, n := range []int{1, 2} { - expectDiscImage(al, n, "album-cover") + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")), + "disc %d should use the album cover when DiscArtPriority is empty", n) } }) }) @@ -201,15 +223,17 @@ var _ = Describe("Disc artwork resolution", func() { "Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}), "Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}), "Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}), - "Artist/Album/disc1/disc1.jpg": smallPNG("disc-1"), - "Artist/Album/disc2/cd2.png": smallPNG("cd-2"), - "Artist/Album/cover.jpg": smallPNG("album-root"), + "Artist/Album/disc1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/disc2/cd2.png": imageFile("cd-2"), + "Artist/Album/cover.jpg": imageFile("album-root"), }) scan() al := firstAlbum() - expectDiscImage(al, 1, "disc-1") - expectDiscImage(al, 2, "cd-2") + disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1"))) + Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2"))) }) }) @@ -222,10 +246,13 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "discsubtitle" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), - "Artist/Album/Bonus Tracks.jpg": smallPNG("bonus-tracks"), + "Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"), }) scan() - expectDiscImage(firstAlbum(), 1, "bonus-tracks") + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks"))) }) }) @@ -259,22 +286,26 @@ var _ = Describe("Disc artwork resolution", func() { "Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))", } layout := fstest.MapFS{ - "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": smallPNG("album-root-cover"), + "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"), } for i, name := range discNames { discNum := i + 1 prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name) layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)}) - layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", discNum)) + layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum)) } setLayout(layout) scan() al := firstAlbum() - expectAlbumFolderCover(al, "(2001) The Golden Road/cover.jpg") + + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) + for i := range discNames { discNum := i + 1 - expectDiscImage(al, discNum, fmt.Sprintf("disc-%02d-folder", discNum)) + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))), + "disc %d should use its own folder.jpg", discNum) } }) }) @@ -297,20 +328,24 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = defaultDiscPriority conf.Server.CoverArtPriority = defaultCoverPriority layout := fstest.MapFS{ - "Album/cover.jpg": smallPNG("album-root-cover"), + "Album/cover.jpg": imageFile("album-root-cover"), } for i := 1; i <= 3; i++ { prefix := fmt.Sprintf("Album/Disc %02d/", i) layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)}) - layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", i)) + layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i)) } setLayout(layout) scan() al := firstAlbum() - expectAlbumFolderCover(al, "Album/cover.jpg") + + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) + for i := 1; i <= 3; i++ { - expectDiscImage(al, i, fmt.Sprintf("disc-%02d-folder", i)) + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))), + "disc %d should use its own folder.jpg", i) } }) }) @@ -324,10 +359,13 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "discsubtitle, cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), - "Artist/Album/cover.jpg": smallPNG("cover"), + "Artist/Album/cover.jpg": imageFile("cover"), }) scan() - expectDiscImage(firstAlbum(), 1, "cover") + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("cover"))) }) }) }) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go deleted file mode 100644 index d03e22228..000000000 --- a/core/artwork/e2e/e2e_suite_test.go +++ /dev/null @@ -1,88 +0,0 @@ -// Package e2e exercises the artwork pipeline end to end: the real Worker drains the queue and the -// real Service serves the result, over a real ImageStore and real library files. -package e2e - -import ( - "context" - "io" - "os" - "path/filepath" - "testing" - "time" - - _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor - "github.com/navidrome/navidrome/core/artwork" - _ "github.com/navidrome/navidrome/core/storage/local" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestArtworkE2E(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Artwork Pipeline E2E Suite") -} - -// Fixtures relative to the project root (tests.Init chdirs there). -const ( - coverFixture = "tests/fixtures/artist/an-album/cover.jpg" - mp3Fixture = "tests/fixtures/artist/an-album/test.mp3" - artistPngFixture = "tests/fixtures/artist/an-album/artist.png" - albumFolderPath = "tests/fixtures/artist/an-album" -) - -func readFixture(rel string) []byte { - GinkgoHelper() - data, err := os.ReadFile(rel) - Expect(err).ToNot(HaveOccurred(), "reading fixture %q", rel) - return data -} - -func readAll(img *artwork.Image) []byte { - GinkgoHelper() - Expect(img).ToNot(BeNil()) - defer img.Close() - data, err := io.ReadAll(img) - Expect(err).ToNot(HaveOccurred()) - return data -} - -func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { - GinkgoHelper() - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { done <- worker.Run(runCtx) }() - // Long enough for one retry (3-7s backoff, 5s poll tick). - Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue()) - cancel() - Eventually(done, 2*time.Second).Should(Receive(BeNil())) -} - -type fakeFolderRepo struct { - model.FolderRepository - result []model.Folder -} - -func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) { - return f.result, nil -} - -func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) { - return false, nil -} - -func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) { - return nil, model.ErrNotFound -} - -func writeUpload(entityType, name, srcFixture string) string { - GinkgoHelper() - dst := model.UploadedImagePath(entityType, name) - Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed()) - Expect(os.WriteFile(dst, readFixture(srcFixture), 0o600)).To(Succeed()) - return name -} diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go new file mode 100644 index 000000000..e3abca097 --- /dev/null +++ b/core/artwork/e2e/helpers_test.go @@ -0,0 +1,184 @@ +package artworke2e_test + +import ( + "bytes" + "context" + _ "embed" + "errors" + "hash/fnv" + "image" + "image/color" + "image/png" + "io" + "maps" + "net/url" + "os" + "path/filepath" + "testing/fstest" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.senan.xyz/taglib" +) + +// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that +// contains a valid MP3 stream with an embedded picture. Used in the +// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't +// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the +// scanner still populates EmbedArtPath via the JSON-tagged track, and the +// artwork reader gets real bytes when it calls libFS.Open. +// +//go:embed testdata/embedded_art.mp3 +var realMP3WithEmbeddedArt []byte + +// embeddedArtBytes is the exact image payload that the artwork reader will +// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can +// assert byte-for-byte equality — if this ever differs it means the reader +// pulled from a different source. +var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt) + +func extractEmbeddedArt(mp3 []byte) []byte { + tf, err := taglib.OpenStream(bytes.NewReader(mp3)) + if err != nil { + panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error()) + } + defer tf.Close() + images := tf.Properties().Images + if len(images) == 0 { + panic("embedded-art fixture has no embedded images") + } + data, err := tf.Image(0) + if err != nil || len(data) == 0 { + panic("embedded-art fixture: could not read image 0") + } + return data +} + +// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative +// path so libFS.Open returns an MP3 stream taglib can parse. +func replaceWithRealMP3(relPath string) { + GinkgoHelper() + fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt} +} + +// placeholderBytes returns the bundled album-placeholder image bytes — the +// same stream the artwork reader emits when every source falls through. +func placeholderBytes() []byte { + GinkgoHelper() + r, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// writeUploadedImage drops `filename` into /artwork// with +// the given bytes, matching the on-disk layout expected by +// model.UploadedImagePath. +func writeUploadedImage(entity, filename string, data []byte) { + GinkgoHelper() + dir := filepath.Dir(model.UploadedImagePath(entity, filename)) + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed()) +} + +func newNoopFFmpeg() *tests.MockFFmpeg { + ff := tests.NewMockFFmpeg("") + ff.Error = errors.New("noop") + return ff +} + +// trackFile builds a FakeFS MP3 entry with optional tag overrides. +func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile { + tags := storagetest.Track(num, title) + for _, e := range extra { + maps.Copy(tags, e) + } + return storagetest.MP3(tags) +} + +// imageFile builds a label-keyed image entry. The bytes are deterministic +// per-label so tests can assert which file won. +func imageFile(label string) *fstest.MapFile { + return &fstest.MapFile{Data: []byte("image:" + label)} +} + +// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by +// tests that feed the bytes into image.Decode (e.g. playlist tiled covers). +func realPNG(label string) *fstest.MapFile { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + // Derive a deterministic color per label. + h := fnv.New32a() + _, _ = h.Write([]byte(label)) + sum := h.Sum32() + c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255} + for y := range 2 { + for x := range 2 { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + Expect(png.Encode(&buf, img)).To(Succeed()) + return &fstest.MapFile{Data: buf.Bytes()} +} + +// imageBytes returns the bytes that imageFile(label) writes. +func imageBytes(label string) []byte { return imageFile(label).Data } + +// setLayout populates fakeFS with the given map. Call after setupHarness. +// All paths must be forward-slash and relative (no leading "/"). +func setLayout(files fstest.MapFS) { + GinkgoHelper() + fakeFS.SetFiles(files) +} + +func readArtwork(artID model.ArtworkID) []byte { + GinkgoHelper() + r, _, err := aw.Get(ctx, artID, 0, false) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return b +} + +func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) { + r, _, err := aw.Get(ctx, artID, 0, false) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +// noopProvider implements external.Provider with not-found returns so the +// "external" priority entry never produces a result. +type noopProvider struct{} + +func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} +func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} +func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} + +var _ external.Provider = (*noopProvider)(nil) diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go index 3ddb9dc48..1f43a3827 100644 --- a/core/artwork/e2e/mediafile_test.go +++ b/core/artwork/e2e/mediafile_test.go @@ -1,4 +1,4 @@ -package e2e +package artworke2e_test import ( "testing/fstest" @@ -17,11 +17,13 @@ import ( // 2. For multi-disc albums, disc-level artwork // 3. Album cover art // -// Embedded art lands in the content-addressed store (asserted byte-for-byte); disc-level art is a -// serve-time read through the library FS. -var _ = Describe("MediaFile artwork resolution", func() { +// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1) +// is covered by the existing embedded-art album tests (which currently +// Skip under FakeFS). The tests below cover (2) and (3): the fallback +// chain for tracks without embedded art. +var _ = Describe("MediaFile artwork fallback", func() { BeforeEach(func() { - setupResolutionHarness() + setupHarness() }) When("a multi-disc album track has no embedded art", func() { @@ -40,14 +42,14 @@ var _ = Describe("MediaFile artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), - "Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"), - "Artist/Album/cover.jpg": smallPNG("album-root"), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), + "Artist/Album/cover.jpg": imageFile("album-root"), }) scan() mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("disc-2"))) + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2"))) }) }) @@ -61,12 +63,12 @@ var _ = Describe("MediaFile artwork resolution", func() { conf.Server.DiscArtPriority = defaultDiscPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": smallPNG("album-cover"), + "Artist/Album/cover.jpg": imageFile("album-cover"), }) scan() mf := mediafileOn("Artist/Album/01 - Track.mp3") - Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-cover"))) + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover"))) }) }) @@ -84,60 +86,19 @@ var _ = Describe("MediaFile artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/cover.jpg": smallPNG("album-root"), + "Artist/Album/cover.jpg": imageFile("album-root"), }) scan() mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-root"))) - }) - }) - - When("a track has its own embedded art", func() { - // Artist/ - // └── Album/ - // └── 01 - Track.mp3 ← has embedded picture (wins over every fallback) - It("resolves the track's embedded image into the store", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), - }) - scan() - replaceWithRealMP3("Artist/Album/01 - Track.mp3") - - mf := mediafileOn("Artist/Album/01 - Track.mp3") - ia := acquire(model.KindMediaFileArtwork, mf.ID) - Expect(ia.Source).To(Equal("embedded")) - Expect(storedBytes(ia)).To(Equal(embeddedArtBytes)) - }) - }) - - When("EnableMediaFileCoverArt is turned off after the track was scanned", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 ← has embedded picture (must NOT be served) - // └── cover.jpg ← wins (per-track art disabled at serve time) - It("serves the album cover instead of the track's embedded art", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), - "Artist/Album/cover.jpg": smallPNG("album-cover"), - }) - scan() - replaceWithRealMP3("Artist/Album/01 - Track.mp3") - - // The setting is not part of the artwork fingerprint, so it must be honored at serve time. - conf.Server.EnableMediaFileCoverArt = false - mf := mediafileOn("Artist/Album/01 - Track.mp3") - trackArtID := model.NewArtworkID(model.KindMediaFileArtwork, mf.ID, nil) - Expect(serveBytes(trackArtID)).To(Equal(pngBytes("album-cover"))) + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root"))) }) }) }) func mediafileOn(relPath string) model.MediaFile { GinkgoHelper() - mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{ + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Like{"media_file.path": relPath}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go index 0d862b20e..d28efca8e 100644 --- a/core/artwork/e2e/playlist_test.go +++ b/core/artwork/e2e/playlist_test.go @@ -1,16 +1,13 @@ -package e2e +package artworke2e_test import ( - "image/color" "os" "path/filepath" "testing/fstest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -20,13 +17,14 @@ import ( // 2. Sidecar image next to the .m3u file (same basename, any image ext) // 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed) // 4. Generated 2x2 tiled cover from the playlist's albums -// 5. Absent +// 5. Album placeholder image // -// The library is an in-memory FS, but uploaded/sidecar/local-external images are real files on -// disk — the resolver reads them via os.Open, so those tests place them in a real tempdir. +// The library FS is FakeFS, but uploaded/sidecar/local-external images are +// real files on disk — the reader reads them via os.Open, so the tests +// place them in a real tempdir under DataFolder. var _ = Describe("Playlist artwork resolution", func() { BeforeEach(func() { - setupResolutionHarness() + setupHarness() }) When("a playlist has an uploaded image", func() { @@ -35,12 +33,11 @@ var _ = Describe("Playlist artwork resolution", func() { // └── playlist/ // └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority) It("returns the uploaded image bytes", func() { - writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", pngBytes("playlist-upload")) + writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload")) + pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"}) - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Source).To(Equal("upload")) - Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("playlist-upload"))) + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload"))) }) }) @@ -51,14 +48,12 @@ var _ = Describe("Playlist artwork resolution", func() { It("returns the sidecar image", func() { dir := GinkgoT().TempDir() m3uPath := filepath.Join(dir, "MyList.m3u") - Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), pngBytes("sidecar"), 0o600)).To(Succeed()) + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed()) pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath}) - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Source).To(Equal("folder")) - Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar"))) + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar"))) }) }) @@ -69,12 +64,12 @@ var _ = Describe("Playlist artwork resolution", func() { It("matches case-insensitively", func() { dir := GinkgoT().TempDir() m3uPath := filepath.Join(dir, "MyList.m3u") - Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), pngBytes("sidecar-png"), 0o600)).To(Succeed()) + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed()) pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath}) - Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar-png"))) + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png"))) }) }) @@ -85,39 +80,31 @@ var _ = Describe("Playlist artwork resolution", func() { conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle dir := GinkgoT().TempDir() imgPath := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(imgPath, pngBytes("external-local"), 0o600)).To(Succeed()) + Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed()) pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath}) - Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("external-local"))) + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local"))) }) }) When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() { - // (no local files — the http source is gated off, so resolution settles absent) - It("skips the URL and settles absent", func() { + // (no local files — http source is gated off, reader falls through to placeholder) + It("skips the URL and falls through to the bundled placeholder", func() { conf.Server.EnableM3UExternalAlbumArt = false + pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"}) - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Hash).To(BeEmpty()) - Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) - - img, err := rsvc.GetOrPlaceholder(rctx, pl.CoverArtID().String(), 0, false) - Expect(err).ToNot(HaveOccurred()) - defer img.Close() - Expect(img.Placeholder).To(BeTrue()) + Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) }) }) When("a playlist has no images and no tracks", func() { - // (no uploaded/sidecar/external image and no album art to sample) - It("settles absent", func() { + // (reader falls all the way through to the bundled album placeholder) + It("returns the album placeholder", func() { pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"}) - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Hash).To(BeEmpty()) - Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) + Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) }) }) @@ -126,79 +113,37 @@ var _ = Describe("Playlist artwork resolution", func() { // Artist/ // ├── AlbumA/ // │ ├── 01 - Track.mp3 - // │ └── cover.png ← tile 1 source + // │ └── cover.png (real PNG — wins as tile 1 source) // └── AlbumB/ // ├── 01 - Track.mp3 - // └── cover.png ← tile 2 source - // Playlist "pl-7" references tracks from both albums, so the worker generates a tiled - // cover from 2 distinct album art tiles (mirrored to fill the 2x2 grid). + // └── cover.png (real PNG — wins as tile 2 source) + // Playlist "pl-7" references tracks from both albums, so the reader + // generates a 2x2 tiled cover from 2 distinct album art tiles (the + // tiled generator mirrors when it has fewer than 4 unique tiles). It("generates a tiled cover from album art", func() { conf.Server.CoverArtPriority = "cover.*" setLayout(fstest.MapFS{ "Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}), - "Artist/AlbumA/cover.png": smallPNG("albumA"), + "Artist/AlbumA/cover.png": realPNG("albumA"), "Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}), - "Artist/AlbumB/cover.png": smallPNG("albumB"), + "Artist/AlbumB/cover.png": realPNG("albumB"), }) scan() - mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{}) + // Pull the scanned mediafile IDs so we can attach them to the playlist. + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"} pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID}) - Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Source).To(Equal("generated")) - data := storedBytes(ia) - // The tiled cover is a PNG-encoded image; exact bytes vary (random album order). + data := readArtwork(pl.CoverArtID()) + // The tiled cover is a PNG-encoded 600x600 image (tileSize const). + // Exact bytes vary (random album order), so assert format + non-trivial size. Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})) - // Two tiles are mirrored into the 2x2 grid as [A B B A], so opposite corners match. - q := gridQuadrants(data) - Expect(q[0]).To(Equal(q[3])) - Expect(q[1]).To(Equal(q[2])) - Expect(q[0]).ToNot(Equal(q[1])) - }) - }) - - When("a playlist has tracks from four albums, each with its own cover", func() { - // Library: - // Artist/ - // ├── AlbumA/{01 - Track.mp3, cover.png} ← tile 1 - // ├── AlbumB/{01 - Track.mp3, cover.png} ← tile 2 - // ├── AlbumC/{01 - Track.mp3, cover.png} ← tile 3 - // └── AlbumD/{01 - Track.mp3, cover.png} ← tile 4 - It("fills all four grid quadrants with distinct album art", func() { - conf.Server.CoverArtPriority = "cover.*" - layout := fstest.MapFS{} - for _, name := range []string{"AlbumA", "AlbumB", "AlbumC", "AlbumD"} { - layout["Artist/"+name+"/01 - Track.mp3"] = trackFile(1, "T"+name, map[string]any{"album": name}) - layout["Artist/"+name+"/cover.png"] = smallPNG(name) - } - setLayout(layout) - scan() - - mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(4)) - ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) - - pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"} - pl.AddMediaFilesByID(ids) - Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed()) - - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Source).To(Equal("generated")) - q := gridQuadrants(storedBytes(ia)) - Expect([]color.RGBA{q[0], q[1], q[2], q[3]}).To(HaveLen(4)) - Expect(q[0]).ToNot(Equal(q[1])) - Expect(q[0]).ToNot(Equal(q[2])) - Expect(q[0]).ToNot(Equal(q[3])) - Expect(q[1]).ToNot(Equal(q[2])) - Expect(q[1]).ToNot(Equal(q[3])) - Expect(q[2]).ToNot(Equal(q[3])) + Expect(len(data)).To(BeNumerically(">", 1000)) }) }) }) @@ -208,6 +153,6 @@ func putPlaylist(pl model.Playlist) model.Playlist { if pl.OwnerID == "" { pl.OwnerID = "admin-1" } - Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) return pl } diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go index 72af215d1..73ee5f377 100644 --- a/core/artwork/e2e/radio_test.go +++ b/core/artwork/e2e/radio_test.go @@ -1,18 +1,15 @@ -package e2e +package artworke2e_test import ( "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// Radio art is uploaded-image-only, with no fallback. Uploads are real files on disk, so they -// serve back byte-for-byte; a radio with no upload settles absent. var _ = Describe("Radio artwork resolution", func() { BeforeEach(func() { - setupResolutionHarness() + setupHarness() }) When("a radio has an uploaded image", func() { @@ -21,25 +18,25 @@ var _ = Describe("Radio artwork resolution", func() { // └── radio/ // └── rd-1_logo.jpg ← matched by UploadedImagePath() It("returns the uploaded image bytes", func() { - writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo")) - rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"} - Expect(rds.Radio().Put(rctx, &rd)).To(Succeed()) + writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo")) - ia := acquire(model.KindRadioArtwork, rd.ID) - Expect(ia.Source).To(Equal("upload")) - Expect(serveBytes(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(Equal(pngBytes("radio-logo"))) + rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"} + Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + + artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo"))) }) }) When("a radio has no uploaded image", func() { - // (no files on disk — the resolver has no sources to fall back to) - It("settles absent", func() { + // (no files on disk — reader has no sources to fall back to) + It("returns ErrUnavailable", func() { rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"} - Expect(rds.Radio().Put(rctx, &rd)).To(Succeed()) + Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) - ia := acquire(model.KindRadioArtwork, rd.ID) - Expect(ia.Hash).To(BeEmpty()) - Expect(serveErr(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(MatchError(artwork.ErrUnavailable)) + artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) + _, err := readArtworkOrErr(artID) + Expect(err).To(HaveOccurred()) }) }) }) diff --git a/core/artwork/e2e/resolution_harness_test.go b/core/artwork/e2e/resolution_harness_test.go deleted file mode 100644 index fbd89925e..000000000 --- a/core/artwork/e2e/resolution_harness_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package e2e - -import ( - "bytes" - "context" - "fmt" - "hash/fnv" - "image" - "image/color" - "image/png" - "io" - "maps" - "os" - "path/filepath" - "strings" - "sync" - "testing/fstest" - "time" - - _ "github.com/navidrome/navidrome/adapters/gotaglib" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/scanner" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/tests/harness" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.senan.xyz/taglib" -) - -const fakeLibScheme = "artworkfake" -const fakeLibPath = fakeLibScheme + ":///music" - -const ( - defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" - defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" -) - -var ( - rctx context.Context - rds *tests.MockDataStore - rstore *artwork.ImageStore - rsvc artwork.Artwork - rworker *artwork.Worker - fakeFS *storagetest.FakeFS -) - -// The go-sqlite3 singleton holds the file open for the whole suite, and Windows cannot unlink a -// file with a live handle, so the DB cannot live in Ginkgo's per-spec TempDir. -var suiteDBTempDir string - -// Migrating the schema costs ~400ms, so it runs once per suite and specs reset by truncating. -var userTables []string - -var _ = BeforeSuite(func() { - suiteDBTempDir = GinkgoT().TempDir() - - DeferCleanup(configtest.SetupConfig()) - conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL" - conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) - db.Db().SetMaxOpenConns(1) - db.Init(request.WithUser(context.Background(), model.User{ID: "admin-1", IsAdmin: true})) - - userTables = harness.ResettableTables() -}) - -var _ = AfterSuite(func() { - db.Close(context.Background()) -}) - -func setupResolutionHarness() { - DeferCleanup(configtest.SetupConfig()) - - tempDir := GinkgoT().TempDir() - conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL" - conf.Server.DataFolder = conf.NewDir(tempDir) - conf.Server.MusicFolder = fakeLibPath - conf.Server.DevExternalScanner = false - conf.Server.ImageCacheSize = "0" - conf.Server.EnableExternalServices = false - conf.Server.EnableMediaFileCoverArt = true - conf.Server.DevArtworkWorkerConcurrency = 1 - - rctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) - harness.TruncateDB(userTables) - - rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - - adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} - Expect(rds.User().Put(rctx, &adminUser)).To(Succeed()) - - lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} - Expect(rds.Library().Put(rctx, &lib)).To(Succeed()) - Expect(rds.User().SetUserLibraries(rctx, adminUser.ID, []int{lib.ID})).To(Succeed()) - - loadEmbeddedFixture() - - fakeFS = &storagetest.FakeFS{} - storagetest.Register(fakeLibScheme, fakeFS) - - ffm := tests.NewMockFFmpeg("") - rstore = artwork.NewImageStore(filepath.Join(tempDir, consts.HashedArtworkFolder)) - // size=0 requests stream originals, so this reader is never called (serving_test covers resizing). - imgCache := cache.NewFileCache("ArtworkResolutionE2E", "100MB", "images", 0, - func(context.Context, cache.Item) (io.Reader, error) { - return nil, fmt.Errorf("resize not exercised in e2e") - }) - Eventually(func() bool { return imgCache.Available(rctx) }, 10*time.Second).Should(BeTrue()) - - rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm) - rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache) -} - -// setLayout paths must be relative and forward-slash. -func setLayout(files fstest.MapFS) { - GinkgoHelper() - fakeFS.SetFiles(files) -} - -func scan() { - GinkgoHelper() - s := scanner.New(rctx, rds, events.NoopBroker(), - playlists.NewPlaylists(rds, artwork.NewUploader(rds)), metrics.NewNoopInstance()) - _, err := s.ScanAll(rctx, true) - Expect(err).ToNot(HaveOccurred()) -} - -func acquire(kind model.Kind, id string) model.ItemArtwork { - GinkgoHelper() - // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. - Expect(rds.ArtworkQueue().EnqueuePreservingBackoff(rctx, model.ArtworkQueueItem{ - ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBump, - })).To(Succeed()) - var ia *model.ItemArtwork - runResolutionWorkerUntil(func() bool { - got, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary) - if err != nil { - return false - } - ia = got - return true - }) - return *ia -} - -func runResolutionWorkerUntil(until func() bool) { - GinkgoHelper() - runCtx, cancel := context.WithCancel(rctx) - done := make(chan error, 1) - go func() { done <- rworker.Run(runCtx) }() - Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue()) - cancel() - Eventually(done, 2*time.Second).Should(Receive(BeNil())) -} - -func serveBytes(artID model.ArtworkID) []byte { - GinkgoHelper() - img, err := rsvc.Get(rctx, artID, 0, false) - Expect(err).ToNot(HaveOccurred()) - defer img.Close() - data, err := io.ReadAll(img) - Expect(err).ToNot(HaveOccurred()) - return data -} - -func serveErr(artID model.ArtworkID) error { - img, err := rsvc.Get(rctx, artID, 0, false) - if img != nil { - img.Close() - } - return err -} - -func libFileBytes(suffix string) []byte { - GinkgoHelper() - var match string - for name := range fakeFS.MapFS { - if strings.HasSuffix(name, suffix) { - Expect(match).To(BeEmpty(), "suffix %q is ambiguous: %q and %q", suffix, match, name) - match = name - } - } - Expect(match).ToNot(BeEmpty(), "no library file ends with %q", suffix) - return fakeFS.MapFS[match].Data -} - -// Serving before acquiring is deliberate: with no state row the request resolves through the -// library FS, while a settled folder row is read with os.Open, which the in-memory FS cannot serve. -func expectAlbumFolderCover(al model.Album, suffix string) { - GinkgoHelper() - requireNoStateRow(model.KindAlbumArtwork, al.ID) - Expect(serveBytes(al.CoverArtID())).To(Equal(libFileBytes(suffix))) - ia := acquire(model.KindAlbumArtwork, al.ID) - Expect(ia.Source).To(Equal("folder")) - Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix)) -} - -// A drain settles every ready item, so byte-level folder assertions must precede any acquire. -func requireNoStateRow(kind model.Kind, id string) { - GinkgoHelper() - _, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), - "assert %s %q before acquiring any other entity in this spec", kind, id) -} - -func expectAlbumAbsent(al model.Album) { - GinkgoHelper() - ia := acquire(model.KindAlbumArtwork, al.ID) - Expect(ia.Hash).To(BeEmpty()) - Expect(serveErr(al.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) -} - -func expectArtistFolder(ar model.Artist, suffix string) { - GinkgoHelper() - requireNoStateRow(model.KindArtistArtwork, ar.ID) - Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(libFileBytes(suffix))) - ia := acquire(model.KindArtistArtwork, ar.ID) - Expect(ia.Source).To(Equal("folder")) - Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix)) -} - -func writeUploadedImage(entity, filename string, data []byte) { - GinkgoHelper() - dst := model.UploadedImagePath(entity, filename) - Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed()) - Expect(os.WriteFile(dst, data, 0o600)).To(Succeed()) -} - -func discArtID(al model.Album, disc int) model.ArtworkID { - return model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, disc), &al.UpdatedAt) -} - -// Disc art is a pure serve-time read through the library FS: no worker, no state row. -func expectDiscImage(al model.Album, disc int, label string) { - GinkgoHelper() - Expect(serveBytes(discArtID(al, disc))).To(Equal(pngBytes(label))) -} - -// Samples in rect() order: top-left, top-right, bottom-left, bottom-right. -func gridQuadrants(data []byte) [4]color.RGBA { - GinkgoHelper() - img, _, err := image.Decode(bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - b := img.Bounds() - qw, qh := b.Dx()/4, b.Dy()/4 - at := func(x, y int) color.RGBA { - c := color.RGBAModel.Convert(img.At(b.Min.X+x, b.Min.Y+y)) - return c.(color.RGBA) - } - return [4]color.RGBA{at(qw, qh), at(3*qw, qh), at(qw, 3*qh), at(3*qw, 3*qh)} -} - -// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath. -func storedBytes(ia model.ItemArtwork) []byte { - GinkgoHelper() - art, err := rds.Artwork().GetImage(rctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - r, err := rstore.Open(ia.Hash, art.Mime) - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - return data -} - -// The pixel color derives from label, so each label yields distinct, still-decodable bytes. -func smallPNG(label string) *fstest.MapFile { - h := fnv.New32a() - _, _ = h.Write([]byte(label)) - sum := h.Sum32() - c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255} - img := image.NewRGBA(image.Rect(0, 0, 2, 2)) - for y := range 2 { - for x := range 2 { - img.Set(x, y, c) - } - } - var buf bytes.Buffer - Expect(png.Encode(&buf, img)).To(Succeed()) - return &fstest.MapFile{Data: buf.Bytes()} -} - -func pngBytes(label string) []byte { - GinkgoHelper() - return smallPNG(label).Data -} - -func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile { - tags := storagetest.Track(num, title) - for _, e := range extra { - maps.Copy(tags, e) - } - return storagetest.MP3(tags) -} - -// FakeFS's JSON-encoded tags aren't taglib-readable, so embedded-art specs swap in these real MP3 -// bytes after scanning. Loaded lazily: tests.Init must chdir to the project root first. -var ( - embeddedFixtureOnce sync.Once - embeddedArtFixture []byte - embeddedArtBytes []byte -) - -func loadEmbeddedFixture() { - embeddedFixtureOnce.Do(func() { - embeddedArtFixture = readFixture(mp3Fixture) - embeddedArtBytes = extractEmbeddedArt(embeddedArtFixture) - }) -} - -func extractEmbeddedArt(mp3 []byte) []byte { - tf, err := taglib.OpenStream(bytes.NewReader(mp3)) - if err != nil { - panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error()) - } - defer tf.Close() - images := tf.Properties().Images - if len(images) == 0 { - panic("embedded-art fixture has no embedded images") - } - data, err := tf.Image(0) - if err != nil || len(data) == 0 { - panic("embedded-art fixture: could not read image 0") - } - return data -} - -func replaceWithRealMP3(relPath string) { - GinkgoHelper() - fakeFS.MapFS[relPath] = &fstest.MapFile{Data: embeddedArtFixture} -} - -func firstAlbum() model.Album { - GinkgoHelper() - albums, err := rds.Album().GetAll(rctx, model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - 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 := rds.Album().GetAll(rctx, 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/e2e/suite_test.go b/core/artwork/e2e/suite_test.go new file mode 100644 index 000000000..9ce0edb8b --- /dev/null +++ b/core/artwork/e2e/suite_test.go @@ -0,0 +1,106 @@ +package artworke2e_test + +import ( + "context" + "path/filepath" + "testing" + + _ "github.com/navidrome/navidrome/adapters/gotaglib" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "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/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestArtworkE2E(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Artwork E2E Suite") +} + +const fakeLibScheme = "artworkfake" +const fakeLibPath = fakeLibScheme + ":///music" + +var ( + ctx context.Context + ds *tests.MockDataStore + aw artwork.Artwork + fakeFS *storagetest.FakeFS +) + +// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps +// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup +// can't unlink a file with a live handle on Windows. A suite-level tempdir +// combined with an AfterSuite close avoids the lock conflict. +var suiteDBTempDir string + +var _ = BeforeSuite(func() { + suiteDBTempDir = GinkgoT().TempDir() +}) + +var _ = AfterSuite(func() { + db.Close(GinkgoT().Context()) +}) + +func setupHarness() { + DeferCleanup(configtest.SetupConfig()) + + tempDir := GinkgoT().TempDir() + // 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.MusicFolder = fakeLibPath + conf.Server.DevExternalScanner = false + conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call + conf.Server.EnableExternalServices = false + + db.Db().SetMaxOpenConns(1) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) + db.Init(ctx) + DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) }) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + + lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} + Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + + fakeFS = &storagetest.FakeFS{} + storagetest.Register(fakeLibScheme, fakeFS) + + aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{}) +} + +func scan() { + GinkgoHelper() + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) +} + +func firstAlbum() model.Album { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) + return albums[0] +} diff --git a/core/artwork/e2e/testdata/embedded_art.mp3 b/core/artwork/e2e/testdata/embedded_art.mp3 new file mode 100644 index 000000000..18cb90674 Binary files /dev/null and b/core/artwork/e2e/testdata/embedded_art.mp3 differ diff --git a/core/artwork/folders_album.go b/core/artwork/folders_album.go deleted file mode 100644 index 7b423adb6..000000000 --- a/core/artwork/folders_album.go +++ /dev/null @@ -1,148 +0,0 @@ -package artwork - -import ( - "cmp" - "context" - "errors" - "path" - "slices" - "strings" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/natural" - "github.com/navidrome/navidrome/utils/slice" -) - -func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.Album) ([]string, []string, *time.Time, error) { - folders, err := loadFolders(ctx, ds, album.FolderIDs) - if err != nil { - return nil, nil, nil, err - } - - parent, err := albumRootParent(ctx, ds, folders, album.FolderIDs) - if err != nil { - return nil, nil, nil, err - } - if parent != nil { - folders = append(folders, *parent) - } - - paths := slice.Map(folders, func(f model.Folder) string { return f.AbsolutePath() }) - imgFiles, updatedAt := folderImages(folders) - return paths, imgFiles, &updatedAt, nil -} - -func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) { - return ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}}) -} - -// folderImages collects the folders' image files, sorted so files without -// numeric suffixes win (e.g. cover.jpg over cover.1.jpg). -func folderImages(folders []model.Folder) ([]string, time.Time) { - var imgFiles []string - var updatedAt time.Time - for _, f := range folders { - if f.ImagesUpdatedAt.After(updatedAt) { - updatedAt = f.ImagesUpdatedAt - } - rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") - for _, img := range f.ImageFiles { - imgFiles = append(imgFiles, path.Join(rel, img)) - } - } - slices.SortFunc(imgFiles, compareImageFiles) - return imgFiles, updatedAt -} - -// 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().Get(ctx, commonParentID) - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Artwork: 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().HasAudioOutsideFolders(ctx, *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 { - return true - } - } - return false -} - -// commonParentFolder returns the shared parent folder ID when all folders have the -// same parent and that parent is not already in folderIDSet. Returns "" otherwise. -func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string { - if len(folders) == 0 { - return "" - } - parentID := folders[0].ParentID - if parentID == "" || folderIDSet[parentID] { - return "" - } - for _, f := range folders[1:] { - if f.ParentID != parentID { - return "" - } - } - return parentID -} - -// compareImageFiles sorts image paths by: base filename (natural order), -// then path depth (shallower first), then full path (stable tiebreaker). -func compareImageFiles(a, b string) int { - // Case-insensitive comparison - a = strings.ToLower(a) - b = strings.ToLower(b) - - // Extract base filenames without extensions - baseA := strings.TrimSuffix(path.Base(a), path.Ext(a)) - baseB := strings.TrimSuffix(path.Base(b), path.Ext(b)) - - // Compare base names first, then prefer shallower paths, then full path as tiebreaker - return cmp.Or( - natural.Compare(baseA, baseB), - cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")), - natural.Compare(a, b), - ) -} diff --git a/core/artwork/folders_album_test.go b/core/artwork/folders_album_test.go deleted file mode 100644 index a263dbaaf..000000000 --- a/core/artwork/folders_album_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// The e2e specs cover which image wins for a layout; these pin what a layout cannot reach: that -// the album-root parent is only fetched when it could qualify, and what happens when that fails. -var _ = Describe("loadAlbumFoldersPaths", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - repo *fakeFolderRepo - album model.Album - now time.Time - ) - - BeforeEach(func() { - ctx = context.Background() - now = time.Now().Truncate(time.Second) - repo = &fakeFolderRepo{} - ds = &tests.MockDataStore{MockedFolder: repo} - album = model.Album{ - ID: "album1", - Name: "Album", - FolderIDs: []string{"folder1", "folder2", "folder3"}, - } - }) - - It("does not query the parent when it is already one of the album's folders", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist", Name: "Album", ParentID: "folder2", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - {ID: "folder2", Path: "", Name: "Artist", ImagesUpdatedAt: now}, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) - Expect(repo.getCallCount).To(BeZero()) - }) - - It("does not query the parent when the album's folders have different parents", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist1/Album", Name: "part1", ParentID: "parentA", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - {ID: "folder2", Path: "Artist2/Album", Name: "part2", ParentID: "parentB", - ImagesUpdatedAt: now}, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist1/Album/part1/cover.jpg")) - Expect(repo.getCallCount).To(BeZero()) - }) - - It("does not query the parent for a single-folder album that has images of its own", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist", Name: "Album", ParentID: "artistFolder", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) - Expect(repo.getCallCount).To(BeZero()) - }) - - It("does not promote the library root, so its images never become album art", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: ".", Name: "AlbumPart1", ParentID: "rootFolder", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - {ID: "folder2", Path: ".", Name: "AlbumPart2", ParentID: "rootFolder", - ImagesUpdatedAt: now}, - } - repo.parentResult = &model.Folder{ID: "rootFolder", Name: ".", ImageFiles: []string{"unrelated.jpg"}} - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("AlbumPart1/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("does not promote a parent that holds another album's audio", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder", - ImagesUpdatedAt: now}, - } - repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", - ParentID: "artistFolder", ImageFiles: []string{"artist.jpg"}} - repo.hasOtherAudio = true - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg")) - }) - - It("promotes the album root parent into the returned paths", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder", - ImagesUpdatedAt: now}, - {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder", - ImagesUpdatedAt: now}, - } - repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", - ParentID: "artistFolder", ImageFiles: []string{"cover.jpg"}} - - paths, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) - Expect(paths).To(HaveLen(3)) - }) - - It("propagates errors from the album-root check", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist/Album", Name: "disc1", ParentID: "albumFolder", - ImagesUpdatedAt: now}, - } - repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", - ParentID: "artistFolder", 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 the parent folder lookup", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "parentFolder", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "parentFolder", - ImagesUpdatedAt: now}, - } - repo.getErr = errors.New("db connection failed") - - _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).To(MatchError("db connection failed")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("continues when the parent folder has been deleted", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "missingParent", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "missingParent", - ImagesUpdatedAt: now}, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) -}) - -// folderImages is the sort that decides which of several same-named images wins, so it is pinned -// directly rather than through a layout that can only show the winner. -var _ = Describe("folderImages", func() { - It("prefers base filenames over numeric-suffixed ones", func() { - imgFiles, _ := folderImages([]model.Folder{ - {Path: "Artist", Name: "Album", ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}}, - }) - - Expect(imgFiles).To(HaveExactElements( - "Artist/Album/cover.jpg", "Artist/Album/cover.1.jpg", "Artist/Album/cover.2.jpg")) - }) - - It("prefers shallower paths when the base filenames tie", func() { - imgFiles, _ := folderImages([]model.Folder{ - {Path: "Artist/Album", Name: "CD1", ImageFiles: []string{"cover.jpg"}}, - {Path: "Artist", Name: "Album", ImageFiles: []string{"cover.jpg"}}, - }) - - Expect(imgFiles).To(HaveExactElements("Artist/Album/cover.jpg", "Artist/Album/CD1/cover.jpg")) - }) - - It("sorts case-insensitively", func() { - imgFiles, _ := folderImages([]model.Folder{ - {Path: "Artist", Name: "Album", ImageFiles: []string{"Cover.jpg", "back.JPG"}}, - }) - - Expect(imgFiles).To(HaveExactElements("Artist/Album/back.JPG", "Artist/Album/Cover.jpg")) - }) - - It("reports the newest ImagesUpdatedAt across the folders", func() { - now := time.Now().Truncate(time.Second) - newest := now.Add(5 * time.Minute) - - _, updatedAt := folderImages([]model.Folder{ - {Path: "Artist", Name: "Album", ImagesUpdatedAt: now}, - {Path: "Artist/Album", Name: "CD1", ImagesUpdatedAt: newest}, - }) - - Expect(updatedAt).To(Equal(newest)) - }) -}) diff --git a/core/artwork/folders_artist.go b/core/artwork/folders_artist.go deleted file mode 100644 index 3403935db..000000000 --- a/core/artwork/folders_artist.go +++ /dev/null @@ -1,210 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "fmt" - "io" - "io/fs" - "os" - "path" - "path/filepath" - "slices" - "strings" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" -) - -const ( - maxArtistFolderTraversalDepth = 3 -) - -func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc { - return func() (io.ReadCloser, string, error) { - if libFS == nil { - return nil, "", fmt.Errorf("artist folder lookup unavailable") - } - rel, err := filepath.Rel(libPath, artistFolder) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath) - } - // fs.Glob needs forward slashes; filepath.Rel returns backslashes on Windows. - rel = filepath.ToSlash(rel) - current := artistFolder - var unreadable error - for range maxArtistFolderTraversalDepth { - reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern) - if err == nil { - return reader, hit, nil - } - if errors.Is(err, errSourceUnreadable) { - unreadable = err - } - if rel == "." { - break // reached library root - } - rel = path.Dir(rel) - current = filepath.Dir(current) - } - if unreadable != nil { - return nil, "", unreadable - } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder) - } -} - -// findImageInFolder returns the first image matching pattern; absFolder is only used for -// the returned display path and log messages. -func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) { - log.Trace(ctx, "Artwork: Looking for artist image", "pattern", pattern, "folder", absFolder) - globPattern := pattern - if relFolder != "." { - globPattern = path.Join(escapeGlobLiteral(relFolder), pattern) - } - matches, err := fs.Glob(libFS, globPattern) - if err != nil { - log.Warn(ctx, "Artwork: Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err) - return nil, "", err - } - - imagePaths := slice.Filter(matches, model.IsImageFile) - - // Prefer base filenames over numeric-suffixed ones (artist.jpg before artist.1.jpg) - slices.SortFunc(imagePaths, compareImageFiles) - - var openErr error - for _, p := range imagePaths { - f, err := libFS.Open(p) - if err != nil { - log.Warn(ctx, "Artwork: Could not open cover art file", "file", p, err) - openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, p, err) - continue - } - _, name := path.Split(p) - return f, filepath.Join(absFolder, name), nil - } - if openErr != nil { - return nil, "", openErr - } - - return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder) -} - -func escapeGlobLiteral(s string) string { - var b strings.Builder - b.Grow(len(s)) - for _, r := range s { - switch r { - case '\\', '*', '?', '[', ']': - b.WriteByte('\\') - } - b.WriteRune(r) - } - return b.String() -} - -// loadArtistAlbumRoots returns one path per album — the deepest folder holding -// all of that album's tracks — so an album split into disc subfolders can't -// pull the artist folder's common prefix below the artist level. -func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) { - var folderIDs []string - for _, album := range albums { - folderIDs = append(folderIDs, album.FolderIDs...) - } - folders, err := loadFolders(ctx, ds, folderIDs) - if err != nil { - return nil, nil, nil, err - } - - pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) { - return f.ID, f.AbsolutePath() - }) - var roots []string - for _, album := range albums { - var albumPaths []string - for _, fid := range album.FolderIDs { - if p, ok := pathByID[fid]; ok { - albumPaths = append(albumPaths, p) - } - } - if len(albumPaths) > 0 { - roots = append(roots, commonDir(albumPaths)) - } - } - - imgFiles, updatedAt := folderImages(folders) - return roots, imgFiles, &updatedAt, nil -} - -// commonDir returns the deepest directory containing all paths. Trailing -// separators keep the comparison on segment boundaries, so a shared name -// fragment (".../Album" and ".../Album2") is never read as a shared directory. -func commonDir(paths []string) string { - sep := string(filepath.Separator) - common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep })) - if !strings.HasSuffix(common, sep) { - common, _ = filepath.Split(common) - } - return filepath.Clean(common) -} - -func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { - if len(albums) == 0 { - return "", time.Time{}, nil - } - libID := albums[0].LibraryID // TODO: Support albums spanning multiple libraries - - // paths holds one root per album: two or more distinct roots already meet at - // the artist folder, while a single root is an album folder needing a climb. - roots := slices.Compact(slices.Sorted(slices.Values(paths))) - folderPath := commonDir(roots) - if len(roots) < 2 { - folderPath = filepath.Dir(folderPath) - } - - // Cleaned like the album paths; Join keeps an empty path empty, Clean would return ".". - libPath, _ := ds.Library().GetPath(ctx, libID) - libPath = filepath.Join(libPath) - folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath) - - log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID, - "libPath", libPath, "libID", libID, "albumPaths", paths) - - folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}}) - if err != nil || len(folders) == 0 { - log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID, - "libPath", libPath, "libID", libID, err) - return "", time.Time{}, err - } - return folderPath, folders[0].ImagesUpdatedAt, nil -} - -// findImageInArtistFolder matches an image by MBID or artist name (case-insensitive), "" if none. -func findImageInArtistFolder(folder, mbzArtistID, artistName string) string { - entries, err := os.ReadDir(folder) - if err != nil { - return "" - } - for _, candidate := range []string{mbzArtistID, artistName} { - if candidate == "" { - continue - } - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - base := utils.BaseName(name) - if strings.EqualFold(base, candidate) && model.IsImageFile(name) { - return filepath.Join(folder, name) - } - } - } - return "" -} diff --git a/core/artwork/folders_artist_paths_test.go b/core/artwork/folders_artist_paths_test.go deleted file mode 100644 index 127661891..000000000 --- a/core/artwork/folders_artist_paths_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// commonDir and loadArtistFolder decide how far above the albums the artist folder sits. A layout -// can only show the image that won, so the arithmetic is pinned here. -var _ = Describe("commonDir", func() { - It("returns the folder itself for a single path", func() { - Expect(commonDir([]string{filepath.FromSlash("/music/artist/album")})). - To(Equal(filepath.FromSlash("/music/artist/album"))) - }) - - It("returns the deepest shared folder", func() { - Expect(commonDir([]string{ - filepath.FromSlash("/music/artist/album/cd1"), - filepath.FromSlash("/music/artist/album/cd2"), - })).To(Equal(filepath.FromSlash("/music/artist/album"))) - }) - - It("does not read a shared name fragment as a shared folder", func() { - Expect(commonDir([]string{ - filepath.FromSlash("/music/artist/Album"), - filepath.FromSlash("/music/artist/Album2"), - })).To(Equal(filepath.FromSlash("/music/artist"))) - }) -}) - -var _ = Describe("loadArtistFolder", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - repo *fakeFolderRepo - albums model.Albums - updatedAt time.Time - ) - - BeforeEach(func() { - ctx = context.Background() - updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute) - repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}} - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 1, Path: filepath.FromSlash("/music")}}) - ds = &tests.MockDataStore{MockedFolder: repo, MockedLibrary: libRepo} - albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}} - }) - - It("returns empty when the artist has no albums", func() { - folder, upd, err := loadArtistFolder(ctx, ds, model.Albums{}, []string{"/dummy/path"}) - - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(BeEmpty()) - Expect(upd).To(BeZero()) - }) - - It("climbs above the album folder when the artist has a single album root", func() { - folder, upd, err := loadArtistFolder(ctx, ds, albums, - []string{filepath.FromSlash("/music/artist/album1")}) - - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(updatedAt)) - }) - - It("climbs above the shared folder when two albums live in the same one", func() { - folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ - filepath.FromSlash("/music/artist/split"), - filepath.FromSlash("/music/artist/split"), - }) - - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(updatedAt)) - }) - - It("stops at the folder where distinct album roots already meet", func() { - folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ - filepath.FromSlash("/music/artist/album1"), - filepath.FromSlash("/music/artist/album2"), - }) - - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(updatedAt)) - }) - - It("returns the error when the folder lookup fails", func() { - repo.err = errors.New("fake error") - - folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ - filepath.FromSlash("/music/artist/album1"), - filepath.FromSlash("/music/artist/album2"), - }) - - Expect(err).To(MatchError(ContainSubstring("fake error"))) - Expect(folder).To(BeEmpty()) - Expect(upd).To(BeZero()) - }) -}) diff --git a/core/artwork/folders_artist_test.go b/core/artwork/folders_artist_test.go deleted file mode 100644 index a35e40232..000000000 --- a/core/artwork/folders_artist_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io/fs" - "testing/fstest" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// unreadableFS globs like its embedded MapFS but refuses to open anything. Injecting the error -// keeps this independent of the filesystem: os.Chmod does not restrict read access on Windows. -type unreadableFS struct{ fstest.MapFS } - -func (u unreadableFS) Open(string) (fs.File, error) { return nil, fs.ErrPermission } - -var _ = Describe("findImageInFolder", func() { - var ctx context.Context - var files fstest.MapFS - - BeforeEach(func() { - ctx = context.Background() - files = fstest.MapFS{"artist.jpg": &fstest.MapFile{Data: []byte("img")}} - }) - - It("returns the first matching image", func() { - r, hit, err := findImageInFolder(ctx, files, ".", "/lib", "artist.*") - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - Expect(hit).To(HaveSuffix("artist.jpg")) - }) - - // The glob matched, so the image exists; failing to open it says nothing about whether the - // artist has one, and must not let the resolver settle on absent. - It("reports a matched but unreadable image as unreadable, not as a miss", func() { - _, _, err := findImageInFolder(ctx, unreadableFS{files}, ".", "/lib", "artist.*") - Expect(err).To(MatchError(errSourceUnreadable)) - }) - - It("reports a plain miss when nothing matches", func() { - _, _, err := findImageInFolder(ctx, files, ".", "/lib", "nothing.*") - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, errSourceUnreadable)).To(BeFalse(), "no match is definitive, not transient") - }) -}) diff --git a/core/artwork/gate.go b/core/artwork/gate.go deleted file mode 100644 index aa2ad8f2f..000000000 --- a/core/artwork/gate.go +++ /dev/null @@ -1,171 +0,0 @@ -package artwork - -import ( - "cmp" - "context" - "errors" - "io" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "golang.org/x/time/rate" -) - -const ( - breakerThreshold = 5 - breakerProbeAfter = time.Minute - // breakerRecoveries is how many consecutive answers an open breaker needs before it trusts the - // provider again. One is not enough: a provider that is rate-limiting or blocking us still - // answers the occasional request, and closing on the first of those puts the agent straight - // back to full rate, which is what earns the next block. - breakerRecoveries = 3 -) - -var errBreakerOpen = errors.New("artwork: external circuit breaker open") - -// gateFunc gates one named external fetch (rate limit + circuit breaker per name). -type gateFunc = func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) - -func passthroughGate(_ string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - return f() -} - -// isTransientExternal reports whether an external failure is worth retrying; a not-found -// (from either package) is a definitive answer, not a fault. -func isTransientExternal(err error) bool { - return err != nil && !errors.Is(err, agents.ErrNotFound) && !errors.Is(err, model.ErrNotFound) -} - -// extGate is one agent's rate limiter and circuit breaker, so a failing provider backs off -// in isolation from the others. -type extGate struct { - limiter *rate.Limiter - breaker *breaker -} - -// gate runs a named external step through that agent's rate limiter and circuit breaker. -func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - g := w.gateFor(name) - allowed, gen := g.breaker.allow() - if !allowed { - log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name) - return nil, "", errBreakerOpen - } - // Timed separately so a throttled agent isn't mistaken for a slow provider. - waitStart := time.Now() - if err := g.limiter.Wait(w.runCtx); err != nil { - return nil, "", err - } - callStart := time.Now() - r, path, err := f() - g.breaker.record(name, gen, err) - log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil, - "limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err) - return r, path, err -} - -// gateFor lazily creates the per-name gate on first use. -func (w *Worker) gateFor(name string) *extGate { - w.gatesMu.Lock() - defer w.gatesMu.Unlock() - if g, ok := w.gates[name]; ok { - return g - } - rps := conf.Server.DevArtworkExternalMaxRPS - limit := rate.Inf - if rps > 0 { - limit = rate.Limit(rps) - } - g := &extGate{limiter: rate.NewLimiter(limit, max(1, rps)), breaker: newBreaker()} - w.gates[name] = g - return g -} - -// breaker opens after breakerThreshold consecutive errors and admits a single probe once -// breakerProbeAfter has elapsed; it closes after breakerRecoveries consecutive answers. -type breaker struct { - mu sync.Mutex - failures int - openedAt time.Time - // recoveries counts consecutive good answers while open; a single failure discards them. - recoveries int - // generation identifies the current open episode, so an answer from a call admitted before - // the breaker opened cannot be mistaken for evidence that it has recovered. - generation int - // probeAfter overrides the probe delay for the current episode when a provider named its own - // back-off; zero falls back to breakerProbeAfter. - probeAfter time.Duration -} - -func newBreaker() *breaker { return &breaker{} } - -// allow reports whether a call may proceed, and the open episode it was admitted under: zero -// when the breaker was closed, the current generation when admitted as a half-open probe. -func (b *breaker) allow() (bool, int) { - b.mu.Lock() - defer b.mu.Unlock() - if b.failures < breakerThreshold { - return true, 0 - } - if time.Since(b.openedAt) >= cmp.Or(b.probeAfter, breakerProbeAfter) { - b.openedAt = time.Now() // start a fresh probe window so only one caller passes - return true, b.generation - } - return false, 0 -} - -func (b *breaker) record(name string, gen int, err error) { - // A cancelled run says nothing about the provider, so it neither counts nor clears. - if errors.Is(err, context.Canceled) { - return - } - b.mu.Lock() - defer b.mu.Unlock() - // An explicit back-off is a definitive "stop for this long", so it opens the breaker at once - // with the provider's own delay instead of waiting for the failure threshold. - if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok && retry.RetryIn > 0 { - b.recoveries = 0 - b.failures = breakerThreshold - b.openedAt = time.Now() - b.probeAfter = retry.RetryIn - b.generation++ - log.Warn("Artwork: Circuit breaker opened for agent, provider asked to back off", "agent", name, - "probeAfter", retry.RetryIn) - return - } - if isTransientExternal(err) { - b.recoveries = 0 - b.failures++ - if b.failures == breakerThreshold { - b.openedAt = time.Now() - b.probeAfter = 0 - b.generation++ - log.Warn("Artwork: Circuit breaker opened for agent", "agent", name, - "consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err) - } - return - } - if b.failures < breakerThreshold { - b.failures = 0 - return - } - // Only a probe from this open episode is evidence of recovery. The worker drains concurrently, - // so answers keep arriving from calls admitted before the breaker opened; counting those would - // close it with no probe interval elapsed, which is the burst this exists to prevent. - if gen == 0 || gen != b.generation { - return - } - // A not-found counts because the provider did answer, but on its own it is thin evidence that - // a provider which just blocked us is well. - b.recoveries++ - if b.recoveries < breakerRecoveries { - return - } - log.Info("Artwork: Circuit breaker closed for agent", "agent", name, - "consecutiveAnswers", b.recoveries) - b.failures, b.recoveries = 0, 0 -} diff --git a/core/artwork/gate_test.go b/core/artwork/gate_test.go deleted file mode 100644 index a954d4cac..000000000 --- a/core/artwork/gate_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package artwork - -import ( - "errors" - "time" - - "github.com/navidrome/navidrome/core/agents" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// allowed drops the generation token when a caller only cares about admission. -func allowed(b *breaker) bool { ok, _ := b.allow(); return ok } - -var _ = Describe("breaker", func() { - // The worker drains concurrently, so when the breaker opens there are already calls past - // allow(), queued in the rate limiter or waiting on a response. Their answers arrive - // afterwards. Counting those as recovery closes the breaker with no probe interval elapsed, - // which is the burst the ramp exists to prevent. No clock is involved: the race is an - // ordering, so it is reproduced by making the calls in the order concurrency produces. - It("ignores answers from calls admitted before it opened", func() { - b := newBreaker() - - // A batch clears allow() while the breaker is still closed. - for range breakerThreshold + breakerRecoveries { - ok, gen := b.allow() - Expect(ok).To(BeTrue()) - Expect(gen).To(BeZero(), "admitted with the breaker closed, so not a probe") - } - - // The fast failures in that batch open it. - for range breakerThreshold { - b.record("agentA", 0, errors.New("blocked")) - } - Expect(allowed(b)).To(BeFalse(), "breaker is open") - - // The slower answers from the same batch land now. - for range breakerRecoveries { - b.record("agentA", 0, nil) - } - - Expect(allowed(b)).To(BeFalse(), - "answers from calls admitted before the breaker opened must not close it") - }) - - It("opens at once when a provider asks to retry later, honoring its delay", func() { - b := newBreaker() - Expect(allowed(b)).To(BeTrue(), "starts closed") - - // A single explicit back-off opens the breaker without reaching the failure threshold. - b.record("agentA", 0, &agents.RetryLaterError{RetryIn: 5 * time.Second}) - - Expect(allowed(b)).To(BeFalse(), "an explicit back-off opens the breaker immediately") - Expect(b.probeAfter).To(Equal(5*time.Second), "the provider's delay drives the probe interval") - }) -}) diff --git a/core/artwork/hash_encoder_bench_test.go b/core/artwork/hash_encoder_bench_test.go deleted file mode 100644 index 2bc4b90ba..000000000 --- a/core/artwork/hash_encoder_bench_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package artwork - -import ( - "fmt" - "image" - "testing" - - "github.com/navidrome/navidrome/core/artwork/blurhash" - "github.com/navidrome/navidrome/core/artwork/thumbhash" -) - -// hashEncoders are the two placeholder hashes decodeArtwork computes from one shared thumbnail. -var hashEncoders = []struct { - name string - encode func(image.Image) error -}{ - {"blurhash", func(img image.Image) error { _, err := blurhash.Encode(img); return err }}, - {"thumbhash", func(img image.Image) error { _, err := thumbhash.Encode(img); return err }}, -} - -func benchEncoder(b *testing.B, encode func(image.Image) error, img image.Image) { - b.Helper() - b.ReportAllocs() - for b.Loop() { - if err := encode(img); err != nil { - b.Fatal(err) - } - } -} - -// BenchmarkHashEncodersAtInputSize is the bar: both encoders are handed the identical image -// makeThumbnail produces, so neither is measured with a conversion the other avoids. -func BenchmarkHashEncodersAtInputSize(b *testing.B) { - img := gradientNRGBA(thumbnailSize) - for _, e := range hashEncoders { - b.Run(e.name, func(b *testing.B) { benchEncoder(b, e.encode, img) }) - } -} - -// BenchmarkHashEncoders sweeps past the pipeline's input size, where each package's own defensive -// downscale starts to dominate. thumbnailSize itself is covered by the benchmark above. -func BenchmarkHashEncoders(b *testing.B) { - for _, size := range []int{300, 600, 900, 1200, 1500} { - img := gradientNRGBA(size) - for _, e := range hashEncoders { - b.Run(fmt.Sprintf("%s/%dx%d", e.name, size, size), func(b *testing.B) { - benchEncoder(b, e.encode, img) - }) - } - } -} diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go deleted file mode 100644 index 5ec701541..000000000 --- a/core/artwork/housekeeping.go +++ /dev/null @@ -1,173 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "slices" - "strconv" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/zeebo/xxh3" -) - -// ReprocessKinds omits media files: they resolve embedded-only, at scan or on view. Artists lead -// so bulk enqueues give the most external-dependent kind a queue headstart. -var ReprocessKinds = []model.Kind{ - model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, -} - -// KeepsState reports whether a kind is recorded in item_artwork and the artwork queue. Disc -// artwork is read through on every request and cached by content key, so it has neither. -func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork } - -// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds -// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker -// resolves them, it just never enumerates them in bulk. -var RefreshableKinds = append(slices.Clone(ReprocessKinds), model.KindMediaFileArtwork) - -// settlesAbsentOnGiveUp reports whether an exhausted retry budget records an absent state. Media -// files are excluded because retrying one costs nothing: they resolve embedded-only, from a local -// read, and only a view ever enqueues them. -func settlesAbsentOnGiveUp(prefix string) bool { - kind, ok := model.ParseKind(prefix) - return ok && KeepsState(kind) && kind != model.KindMediaFileArtwork -} - -// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change. -const artworkEpoch = 1 - -// FingerprintInput is one config value the fingerprint covers, named after the setting it came from. -type FingerprintInput struct { - Name string - Value string -} - -// FingerprintInputs is the single listing of what ConfigFingerprint hashes. -func FingerprintInputs() []FingerprintInput { - return []FingerprintInput{ - {"CoverArtPriority", conf.Server.CoverArtPriority}, - {"ArtistArtPriority", conf.Server.ArtistArtPriority}, - {"ArtistImageFolder", conf.Server.ArtistImageFolder}, - {"Agents", conf.Server.Agents}, - {"EnableExternalServices", strconv.FormatBool(conf.Server.EnableExternalServices)}, - {"EnableM3UExternalAlbumArt", strconv.FormatBool(conf.Server.EnableM3UExternalAlbumArt)}, - } -} - -// ConfigFingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state. -func ConfigFingerprint() string { - values := slice.Map(FingerprintInputs(), func(i FingerprintInput) string { return i.Value }) - raw := fmt.Sprintf("%s|%d", strings.Join(values, "|"), artworkEpoch) - return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw))) -} - -// ReconcileConfigFingerprint warns when the artwork config changed since the library was last -// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess. -func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error { - current := ConfigFingerprint() - stored, err := ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, "") - if err != nil { - return err - } - switch stored { - case current: - case "": - // An unset fingerprint counts as current; the alternative warns every upgrading install once. - return MarkConfigApplied(ctx, ds) - default: - log.Warn(ctx, "Artwork: Config changed since the last full reprocess. Stored artwork keeps "+ - "the old resolution; run 'navidrome artwork reprocess --all' to apply the change", - "stored", stored, "current", current, "inputs", FingerprintInputs()) - } - return nil -} - -// MarkConfigApplied records the current fingerprint as the one the library is resolved under. -func MarkConfigApplied(ctx context.Context, ds model.DataStore) error { - return ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, ConfigFingerprint()) -} - -// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off). -func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { - queue := ds.ArtworkQueue() - for _, kind := range ReprocessKinds { - if _, err := queue.EnqueueAllMissing(ctx, kind, model.ArtworkPriorityRecheck); err != nil { - return err - } - } - return nil -} - -// ItemName resolves a kind+id to the entity's display name, and errors when the item -// does not exist. Callers use it to reject ids that would otherwise orphan a queue row. -func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) { - switch kind { - case model.KindArtistArtwork: - ar, err := ds.Artist().Get(ctx, id) - if err != nil { - return "", err - } - return ar.Name, nil - case model.KindAlbumArtwork: - al, err := ds.Album().Get(ctx, id) - if err != nil { - return "", err - } - return al.Name, nil - case model.KindPlaylistArtwork: - pls, err := ds.Playlist().Get(ctx, id) - if err != nil { - return "", err - } - return pls.Name, nil - case model.KindRadioArtwork: - rd, err := ds.Radio().Get(ctx, id) - if err != nil { - return "", err - } - return rd.Name, nil - case model.KindMediaFileArtwork: - mf, err := ds.MediaFile().Get(ctx, id) - if err != nil { - return "", err - } - return mf.Title, nil - case model.KindDiscArtwork: - return discArtworkName(ctx, ds, id) - } - return "", fmt.Errorf("unsupported kind %q", kind.Prefix()) -} - -func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) { - albumID, discNumber, err := model.ParseDiscArtworkID(id) - if err != nil { - return "", err - } - al, err := ds.Album().Get(ctx, albumID) - if err != nil { - return "", err - } - name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber) - // The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it. - if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" { - name += ": " + subtitle - } - return name, nil -} - -// Refresh drops an item's resolved artwork state and re-queues it at Bump priority. -func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error { - if err := ds.Artwork().DeleteForItems(ctx, kind, []string{id}); err != nil { - return fmt.Errorf("clearing artwork state: %w", err) - } - item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump} - if err := ds.ArtworkQueue().Enqueue(ctx, item); err != nil { - return fmt.Errorf("enqueuing artwork refresh: %w", err) - } - return nil -} diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go deleted file mode 100644 index 0f501d0b1..000000000 --- a/core/artwork/housekeeping_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package artwork - -import ( - "context" - "slices" - - "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/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("RefreshableKinds", func() { - // The two are meant to describe the same fact. Nothing but this test stops them from drifting, - // and a drift would have `artwork explain` report state for a kind that keeps none. - It("holds exactly the kinds that keep state", func() { - for _, k := range []model.Kind{ - model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, - model.KindRadioArtwork, model.KindMediaFileArtwork, model.KindDiscArtwork, - } { - Expect(slices.Contains(RefreshableKinds, k)).To(Equal(KeepsState(k)), k.String()) - } - }) -}) - -var _ = Describe("Housekeeping", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - queueRepo *tests.MockArtworkQueueRepo - propRepo *tests.MockedPropertyRepo - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - conf.Server.CoverArtPriority = "embedded, folder" - conf.Server.ArtistArtPriority = "artist.jpg" - conf.Server.Agents = "spotify" - conf.Server.EnableExternalServices = true - - queueRepo = tests.CreateMockArtworkQueueRepo() - propRepo = &tests.MockedPropertyRepo{} - ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo} - }) - - Describe("Fingerprint", func() { - DescribeTable("changes when a fingerprint-affecting config value changes", - func(change func()) { - before := ConfigFingerprint() - change() - Expect(ConfigFingerprint()).NotTo(Equal(before)) - }, - Entry("CoverArtPriority", func() { conf.Server.CoverArtPriority = "folder, embedded" }), - Entry("ArtistImageFolder", func() { conf.Server.ArtistImageFolder = "/after" }), - Entry("EnableM3UExternalAlbumArt", func() { conf.Server.EnableM3UExternalAlbumArt = true }), - ) - - // Pinned: a changed formula tells every existing install its artwork config went stale. - It("hashes a given config to a stable value", func() { - conf.Server.CoverArtPriority = "cover.*, embedded" - conf.Server.ArtistArtPriority = "artist.*, external" - conf.Server.ArtistImageFolder = "" - conf.Server.Agents = "lastfm,spotify" - conf.Server.EnableExternalServices = true - conf.Server.EnableM3UExternalAlbumArt = false - - Expect(ConfigFingerprint()).To(Equal("7b538a83a870c16d")) - }) - - It("reports the config inputs it hashes, so a change can be traced to a setting", func() { - conf.Server.Agents = "lastfm,spotify" - conf.Server.CoverArtPriority = "cover.*, embedded" - - Expect(FingerprintInputs()).To(ContainElements( - FingerprintInput{Name: "Agents", Value: "lastfm,spotify"}, - FingerprintInput{Name: "CoverArtPriority", Value: "cover.*, embedded"}, - )) - }) - - It("does not change when the server version changes", func() { - original := consts.Version - DeferCleanup(func() { consts.Version = original }) - f1 := ConfigFingerprint() - consts.Version = original + "-next" - Expect(ConfigFingerprint()).To(Equal(f1), - "the version must not invalidate artwork state: every build would report a stale config") - }) - }) - - Describe("ReconcileConfigFingerprint", func() { - It("records the current fingerprint when none was ever stored", func() { - Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - - Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint())) - }) - - It("leaves a stale fingerprint stored, so the warning survives a restart", func() { - Expect(propRepo.Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) - - Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - - Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint")) - }) - }) - - Describe("EnqueueMissingAll", func() { - var artRepo *tests.MockArtworkRepo - - BeforeEach(func() { - artRepo = tests.CreateMockArtworkRepo() - ds.MockedArtwork = artRepo - queueRepo.ItemArtworkSource = artRepo - queueRepo.ExistingIDs = map[string]map[string]bool{ - "al": {"al1": true, "al2": true}, - "ar": {"ar1": true}, - "pl": {"pl1": true}, - "ra": {"ra1": true}, - } - }) - - It("enqueues only entities that have no item_artwork row, across all kinds", func() { - artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash"} - artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: ""} - - err := enqueueMissingAll(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - - for _, it := range queueRepo.Data { - Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) - } - Expect(findQueued(queueRepo, "al", "al2")).ToNot(BeNil()) - Expect(findQueued(queueRepo, "pl", "pl1")).ToNot(BeNil()) - Expect(findQueued(queueRepo, "ra", "ra1")).ToNot(BeNil()) - Expect(findQueued(queueRepo, "al", "al1")).To(BeNil()) - Expect(findQueued(queueRepo, "ar", "ar1")).To(BeNil()) - }) - }) -}) - -var _ = Describe("ItemName", func() { - var ds *tests.MockDataStore - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{ - {ID: "al-1", Name: "Kid A"}, - {ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}, - }) - ds = &tests.MockDataStore{MockedAlbum: albumRepo} - Expect(ds.Artist().(*tests.MockArtistRepo).Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) - }) - - It("returns the album name", func() { - Expect(ItemName(ctx, ds, model.KindAlbumArtwork, "al-1")).To(Equal("Kid A")) - }) - - It("returns the artist name", func() { - Expect(ItemName(ctx, ds, model.KindArtistArtwork, "ar-1")).To(Equal("Radiohead")) - }) - - It("errors for an unknown album", func() { - _, err := ItemName(ctx, ds, model.KindAlbumArtwork, "nope") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("errors for an unsupported kind", func() { - // model.Kind is a struct with unexported fields, so the zero value is the only - // unsupported Kind constructible from outside package model. - _, err := ItemName(ctx, ds, model.Kind{}, "al-1") - Expect(err).To(HaveOccurred()) - }) - - Context("disc artwork", func() { - It("names the album, the disc and its subtitle", func() { - Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:2")). - To(Equal("Sandinista! (disc 2): Side Three")) - }) - - It("omits the subtitle when the disc has none", func() { - Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:1")). - To(Equal("Sandinista! (disc 1)")) - }) - - It("rejects an id that is not :", func() { - _, err := ItemName(ctx, ds, model.KindDiscArtwork, "al-2") - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go index b1970d21d..ac0f63794 100644 --- a/core/artwork/image_cache.go +++ b/core/artwork/image_cache.go @@ -1,22 +1,30 @@ package artwork import ( - "bytes" "context" + "fmt" "io" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/singleton" ) -// artworkReader is the cache.Item the image cache loader dispatches on: Reader -// produces the (possibly resized) bytes to store under Key. -type artworkReader interface { - cache.Item - Reader(ctx context.Context) (io.ReadCloser, error) +type cacheKey struct { + artID model.ArtworkID + lastUpdate time.Time +} + +func (k *cacheKey) Key() string { + return fmt.Sprintf( + "%s-%s.%d", + k.artID.Kind, + k.artID.ID, + k.lastUpdate.UnixMilli(), + ) } type imageCache struct { @@ -28,49 +36,9 @@ func GetImageCache() cache.FileCache { return &imageCache{ FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems, func(ctx context.Context, arg cache.Item) (io.Reader, error) { - return arg.(artworkReader).Reader(ctx) + r, _, err := arg.(artworkReader).Reader(ctx) + return r, err }), } }) } - -// resizedItem is an artworkReader that resizes bytes opened by open() and caches the -// result under a hash-derived key. -type resizedItem struct { - hash string - size int - square bool - ffmpeg ffmpeg.FFmpeg - open func() (io.ReadCloser, error) -} - -// Key is the ETag namespaced for the cache, so the validator a client holds and the entry it -// validates can never drift apart. -func (r *resizedItem) Key() string { - return "h-" + representationTag(r.hash, r.size, r.square) -} - -func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) { - orig, err := r.open() - if err != nil { - return nil, err - } - // An open() that reports "no image" as a nil reader would otherwise panic on the Close below. - if orig == nil { - return nil, ErrUnavailable - } - defer orig.Close() - data, err := readCapped(orig) - if err != nil { - return nil, err - } - resized, _, err := resizeImageData(ctx, r.ffmpeg, data, r.size, r.square) - if err != nil || resized == nil { - // Resize failed or image already within bounds: serve the original bytes. - return io.NopCloser(bytes.NewReader(data)), nil - } - if rc, ok := resized.(io.ReadCloser); ok { - return rc, nil - } - return io.NopCloser(resized), nil -} diff --git a/core/artwork/image_cache_test.go b/core/artwork/image_cache_test.go deleted file mode 100644 index a74c51088..000000000 --- a/core/artwork/image_cache_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("resizedItem", func() { - Describe("Reader", func() { - newItem := func(open func() (io.ReadCloser, error)) *resizedItem { - return &resizedItem{hash: "abc123", size: 300, open: open} - } - - It("reports a nil reader as unavailable instead of panicking on it", func() { - // Every caller is expected to report "no image" as an error, but a nil reader reaches - // the deferred Close as a nil interface, which takes the whole request down. - _, err := newItem(func() (io.ReadCloser, error) { return nil, nil }).Reader(context.Background()) - Expect(err).To(MatchError(ErrUnavailable)) - }) - - It("propagates the open error", func() { - boom := errors.New("boom") - _, err := newItem(func() (io.ReadCloser, error) { return nil, boom }).Reader(context.Background()) - Expect(err).To(MatchError(boom)) - }) - - It("serves the original bytes when they cannot be resized", func() { - rc, err := newItem(func() (io.ReadCloser, error) { - return io.NopCloser(strings.NewReader("not an image")), nil - }).Reader(context.Background()) - Expect(err).ToNot(HaveOccurred()) - defer rc.Close() - Expect(io.ReadAll(rc)).To(Equal([]byte("not an image"))) - }) - }) -}) diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go deleted file mode 100644 index 2ded3e636..000000000 --- a/core/artwork/image_store.go +++ /dev/null @@ -1,158 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "fmt" - "io" - "io/fs" - "os" - "path/filepath" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/zeebo/xxh3" -) - -// ImageStore is the content-addressed store for artwork images with no library file backing them. -type ImageStore struct { - root string -} - -func NewImageStore(rootDir string) *ImageStore { - return &ImageStore{root: rootDir} -} - -func GetImageStore() *ImageStore { - return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, consts.HashedArtworkFolder)) -} - -// extForMime must stay stable across OSes: extensions are baked into stored paths and re-derived on Open. -func extForMime(m string) string { - switch m { - case "image/jpeg": - return ".jpg" - case "image/png": - return ".png" - case "image/gif": - return ".gif" - case "image/webp": - return ".webp" - } - return ".img" -} - -func hashImage(r io.Reader) (string, error) { - d := xxh3.New() - if _, err := io.Copy(d, r); err != nil { - return "", err - } - return fmt.Sprintf("%016x", d.Sum64()), nil -} - -// validHash guards path sharding: a malformed hash would slice-panic or inject path separators. -func validHash(hash string) bool { - if len(hash) != 16 { - return false - } - for _, c := range []byte(hash) { - if (c < '0' || c > '9') && (c < 'a' || c > 'f') { - return false - } - } - return true -} - -func (s *ImageStore) path(hash, mimeType string) string { - return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType)) -} - -func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { - if !validHash(hash) { - return fmt.Errorf("imagestore: invalid hash %q", hash) - } - dst := s.path(hash, mimeType) - if _, err := os.Stat(dst); err == nil { - // A touched mtime marks the file live so a concurrent prune spares it. - now := time.Now() - if err := os.Chtimes(dst, now, now); err == nil { - return nil - } - // touch failed (likely pruned concurrently) — fall through and rewrite it - } - if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { - return err - } - tmp, err := os.CreateTemp(filepath.Dir(dst), "."+hash+".tmp*") - if err != nil { - return err - } - defer os.Remove(tmp.Name()) - if _, err := io.Copy(tmp, r); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Chmod(tmp.Name(), 0640); err != nil { - return err - } - return os.Rename(tmp.Name(), dst) -} - -func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) { - if !validHash(hash) { - return nil, fmt.Errorf("imagestore: invalid hash %q", hash) - } - return os.Open(s.path(hash, mimeType)) -} - -// Sweep removes store files not accepted by keep. Files modified after cutoff are always -// kept: their acquisition row may not be committed yet. -func (s *ImageStore) Sweep(ctx context.Context, cutoff time.Time, keep func(hash, ext string) bool) (int, error) { - removed, failed := 0, 0 - var lastErr error - err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - if err := ctx.Err(); err != nil { - return err - } - info, err := d.Info() - if err != nil { - return err - } - if info.ModTime().After(cutoff) { - return nil - } - name := d.Name() - remove := strings.HasPrefix(name, ".") // abandoned temp file past the grace window - if !remove { - ext := filepath.Ext(name) - remove = !keep(strings.TrimSuffix(name, ext), ext) - } - if remove { - // #nosec G122 -- path comes from WalkDir over our own store root, no attacker-controlled symlinks - if err := os.Remove(path); err != nil { - // One unremovable file must not strand the rest of the store until the next prune. - failed, lastErr = failed+1, err - return nil //nolint:nilerr // counted and reported in aggregate below - } - removed++ - } - return nil - }) - // Aggregated: a store that has gone read-only would otherwise warn once per file, every prune. - if failed > 0 { - log.Warn(ctx, "Artwork: Could not remove store files", "count", failed, "swept", removed, lastErr) - } - if errors.Is(err, fs.ErrNotExist) { - return removed, nil - } - return removed, err -} diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go deleted file mode 100644 index 7de3ba761..000000000 --- a/core/artwork/image_store_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "io" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ImageStore", func() { - var store *ImageStore - var root string - var ctx context.Context - - BeforeEach(func() { - root = GinkgoT().TempDir() - store = NewImageStore(root) - ctx = context.Background() - }) - - It("hashes deterministically", func() { - h1, err := hashImage(bytes.NewReader([]byte("some image bytes"))) - Expect(err).ToNot(HaveOccurred()) - h2, _ := hashImage(bytes.NewReader([]byte("some image bytes"))) - Expect(h1).To(Equal(h2)) - Expect(h1).To(HaveLen(16)) - h3, _ := hashImage(bytes.NewReader([]byte("other bytes"))) - Expect(h3).ToNot(Equal(h1)) - }) - - It("writes sharded and reads back", func() { - data := []byte("jpeg-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - - Expect(filepath.Join(root, h[0:2], h[2:4], h+".jpg")).To(BeAnExistingFile()) - - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - defer rc.Close() - got, _ := io.ReadAll(rc) - Expect(got).To(Equal(data)) - }) - - It("writes group-readable image files", func() { - tests.SkipOnWindows("uses Unix file permission bits") - data := []byte("jpeg-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - - info, err := os.Stat(store.path(h, "image/jpeg")) - Expect(err).ToNot(HaveOccurred()) - Expect(info.Mode().Perm()).To(Equal(os.FileMode(0640))) - }) - - It("is idempotent on duplicate writes and preserves the original content", func() { - data := []byte("dup") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - // A duplicate write only touches mtime; passing different bytes under the same - // hash proves the second reader is never consumed to overwrite the file. - Expect(store.Write(h, "image/png", bytes.NewReader([]byte("not-dup")))).To(Succeed()) - - rc, err := store.Open(h, "image/png") - Expect(err).ToNot(HaveOccurred()) - defer rc.Close() - got, err := io.ReadAll(rc) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(Equal(data)) - }) - - It("refreshes the mtime on a duplicate write", func() { - data := []byte("touch-me") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) - - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - - info, err := os.Stat(store.path(h, "image/png")) - Expect(err).ToNot(HaveOccurred()) - Expect(info.ModTime()).To(BeTemporally(">", time.Now().Add(-time.Minute))) - }) - - It("rewrites the bytes when the existing file vanished before the liveness touch", func() { - data := []byte("vanishing") - h, _ := hashImage(bytes.NewReader(data)) - for range 10 { - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - Expect(os.Remove(store.path(h, "image/png"))).To(Succeed()) - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - - rc, err := store.Open(h, "image/png") - Expect(err).ToNot(HaveOccurred()) - got, _ := io.ReadAll(rc) - rc.Close() - Expect(got).To(Equal(data)) - } - }) - - It("returns fs.ErrNotExist for missing images", func() { - _, err := store.Open("beefbeefbeefbeef", "image/jpeg") - Expect(os.IsNotExist(err)).To(BeTrue()) - }) - - It("rejects invalid hashes instead of panicking", func() { - for _, h := range []string{"", "ab", "BEEFBEEFBEEFBEEF", "../../../../etcpw", "beefbeefbeefbee/"} { - Expect(store.Write(h, "image/jpeg", bytes.NewReader([]byte("x")))).To(MatchError(ContainSubstring("invalid hash"))) - _, err := store.Open(h, "image/jpeg") - Expect(err).To(MatchError(ContainSubstring("invalid hash"))) - } - }) - - It("sweeps unknown files, keeps known ones", func() { - d1 := []byte("keep-me") - h1, _ := hashImage(bytes.NewReader(d1)) - Expect(store.Write(h1, "image/jpeg", bytes.NewReader(d1))).To(Succeed()) - d2 := []byte("orphan") - h2, _ := hashImage(bytes.NewReader(d2)) - Expect(store.Write(h2, "image/jpeg", bytes.NewReader(d2))).To(Succeed()) - - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h2, "image/jpeg"), old, old)).To(Succeed()) - - removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(h, _ string) bool { return h == h1 }) - Expect(err).ToNot(HaveOccurred()) - Expect(removed).To(Equal(1)) - _, err = store.Open(h2, "image/jpeg") - Expect(os.IsNotExist(err)).To(BeTrue()) - rc, err := store.Open(h1, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("sweeps a stale mime variant of a known hash, keeps the current one", func() { - data := []byte("same-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) - Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) - - // The recorded mime is image/jpeg, so the .png variant is obsolete. - removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(hash, ext string) bool { - return hash == h && ext == ".jpg" - }) - Expect(err).ToNot(HaveOccurred()) - Expect(removed).To(Equal(1)) - _, err = store.Open(h, "image/png") - Expect(os.IsNotExist(err)).To(BeTrue()) - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("keeps young unknown files inside the grace window", func() { - d := []byte("fresh-orphan") - h, _ := hashImage(bytes.NewReader(d)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(d))).To(Succeed()) - - removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) - Expect(err).ToNot(HaveOccurred()) - Expect(removed).To(Equal(0)) - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("removes abandoned temp files past the grace window, keeps fresh ones", func() { - oldTmp := filepath.Join(root, ".old.tmp") - Expect(os.WriteFile(oldTmp, []byte("x"), 0600)).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(oldTmp, old, old)).To(Succeed()) - - freshTmp := filepath.Join(root, ".fresh.tmp") - Expect(os.WriteFile(freshTmp, []byte("y"), 0600)).To(Succeed()) - - removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return true }) - Expect(err).ToNot(HaveOccurred()) - Expect(removed).To(Equal(1)) - Expect(oldTmp).ToNot(BeAnExistingFile()) - Expect(freshTmp).To(BeAnExistingFile()) - }) - - It("keeps sweeping past a file it cannot remove", func() { - tests.SkipOnWindows("uses Unix file permission bits") - if os.Geteuid() == 0 { - Skip("read-only dir cannot block root (e.g. tests in a container)") - } - old := time.Now().Add(-2 * time.Hour) - // "blocked" sorts before "ok", so the walk hits the unremovable file first. - blockedDir := filepath.Join(root, "blocked") - Expect(os.MkdirAll(blockedDir, 0755)).To(Succeed()) - blocked := filepath.Join(blockedDir, "a.jpg") - Expect(os.WriteFile(blocked, []byte("x"), 0600)).To(Succeed()) - Expect(os.Chtimes(blocked, old, old)).To(Succeed()) - - okDir := filepath.Join(root, "ok") - Expect(os.MkdirAll(okDir, 0755)).To(Succeed()) - reachable := filepath.Join(okDir, "b.jpg") - Expect(os.WriteFile(reachable, []byte("y"), 0600)).To(Succeed()) - Expect(os.Chtimes(reachable, old, old)).To(Succeed()) - - Expect(os.Chmod(blockedDir, 0500)).To(Succeed()) - DeferCleanup(func() { _ = os.Chmod(blockedDir, 0755) }) - - removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) - Expect(err).ToNot(HaveOccurred()) - Expect(removed).To(Equal(1)) - Expect(blocked).To(BeAnExistingFile()) - Expect(reachable).ToNot(BeAnExistingFile()) - }) - - // Prune holds the worker's write lock for the whole sweep, and shutdown waits on the - // worker, so an uncancellable walk over a large store stalls it until SIGKILL. - It("abandons the walk when the context is cancelled", func() { - old := time.Now().Add(-2 * time.Hour) - for _, name := range []string{"a", "b", "c", "d"} { - p := filepath.Join(root, name+".jpg") - Expect(os.WriteFile(p, []byte("x"), 0600)).To(Succeed()) - Expect(os.Chtimes(p, old, old)).To(Succeed()) - } - cancelCtx, cancel := context.WithCancel(ctx) - cancel() - - _, err := store.Sweep(cancelCtx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) - Expect(err).To(MatchError(context.Canceled)) - matches, _ := filepath.Glob(filepath.Join(root, "*.jpg")) - Expect(matches).To(HaveLen(4), "a cancelled sweep must not keep deleting") - }) -}) diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go index 84e088c68..ff557294e 100644 --- a/core/artwork/library_fs.go +++ b/core/artwork/library_fs.go @@ -2,9 +2,7 @@ package artwork import ( "context" - "net/url" "path/filepath" - "strings" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model" @@ -30,7 +28,7 @@ func (v libraryView) Abs(rel string) string { // loadLibraryView resolves the MusicFS and absolute root path in a single // library lookup. func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) { - lib, err := ds.Library().Get(ctx, libID) + lib, err := ds.Library(ctx).Get(libID) if err != nil { return libraryView{}, err } @@ -42,23 +40,5 @@ func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (librar if err != nil { return libraryView{}, err } - return libraryView{FS: fs, absRoot: localOSRoot(lib.Path)}, nil -} - -// localOSRoot maps a library path to its on-disk root so Abs() yields paths os.Open/os.Stat accept: -// a file:// URL becomes its parsed OS path (bare paths already are; non-local schemes stay unchanged). -func localOSRoot(libPath string) string { - if !strings.Contains(libPath, "://") { - return libPath - } - u, err := url.Parse(libPath) - if err != nil || u.Scheme != storage.LocalSchemaID { - return libPath - } - // Windows drive URLs (file://C:/Music) put the volume in Host; rejoin it, matching - // core/storage/local's newLocalStorage so os.Open/os.Stat get a valid path. - if filepath.VolumeName(u.Host) != "" { - return filepath.Join(u.Host, u.Path) - } - return u.Path + return libraryView{FS: fs, absRoot: lib.Path}, nil } diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go index 868c5e45a..acf08fda3 100644 --- a/core/artwork/library_fs_test.go +++ b/core/artwork/library_fs_test.go @@ -24,7 +24,7 @@ var _ = Describe("loadLibraryView", Ordered, func() { }) It("returns a view for a library backed by registered storage", func() { - Expect(ds.Library().Put(ctx, &model.Library{ID: 1, Path: "fake:///music"})).To(Succeed()) + Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed()) lib, err := loadLibraryView(ctx, ds, 1) Expect(err).ToNot(HaveOccurred()) @@ -32,20 +32,13 @@ var _ = Describe("loadLibraryView", Ordered, func() { Expect(lib.absRoot).To(Equal("fake:///music")) }) - It("normalizes a library path to an OS root that Abs can join for os.Open/os.Stat", func() { - // file:// URLs become their parsed OS path; bare paths and non-local schemes are unchanged. - Expect(localOSRoot("file:///music/library")).To(Equal("/music/library")) - Expect(localOSRoot("/music/library")).To(Equal("/music/library")) - Expect(localOSRoot("fake:///music")).To(Equal("fake:///music")) - }) - It("returns an error when the library does not exist", func() { _, err := loadLibraryView(ctx, ds, 999) Expect(err).To(HaveOccurred()) }) It("returns an error when the library path uses an unregistered scheme", func() { - Expect(ds.Library().Put(ctx, &model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed()) + Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed()) _, err := loadLibraryView(ctx, ds, 2) Expect(err).To(HaveOccurred()) }) diff --git a/core/artwork/playlist_cover.go b/core/artwork/playlist_cover.go deleted file mode 100644 index e74b64808..000000000 --- a/core/artwork/playlist_cover.go +++ /dev/null @@ -1,116 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "image" - "image/draw" - "image/png" - "io" - "os" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" - xdraw "golang.org/x/image/draw" -) - -const tileSize = 600 - -// findPlaylistSidecarPath finds an image beside plsPath with the same base name (case-insensitive). -func findPlaylistSidecarPath(ctx context.Context, plsPath string) string { - if plsPath == "" { - return "" - } - dir := filepath.Dir(plsPath) - base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath)) - - entries, err := os.ReadDir(dir) - if err != nil { - log.Warn(ctx, "Artwork: Could not read directory for playlist sidecar", "dir", dir, err) - return "" - } - for _, entry := range entries { - name := entry.Name() - nameBase := utils.BaseName(name) - if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) { - return filepath.Join(dir, name) - } - } - return "" -} - -func rect(pos int) image.Rectangle { - r := image.Rectangle{} - switch pos { - case 1: - r.Min.X = tileSize / 2 - case 2: - r.Min.Y = tileSize / 2 - case 3: - r.Min.X = tileSize / 2 - r.Min.Y = tileSize / 2 - } - r.Max.X = r.Min.X + tileSize/2 - r.Max.Y = r.Min.Y + tileSize/2 - return r -} - -// fillCenter center-crops src and scales it to fill dstW x dstH exactly. -func fillCenter(src image.Image, dstW, dstH int) image.Image { - srcBounds := src.Bounds() - srcW := srcBounds.Dx() - srcH := srcBounds.Dy() - - srcAspect := float64(srcW) / float64(srcH) - dstAspect := float64(dstW) / float64(dstH) - - var cropRect image.Rectangle - if srcAspect > dstAspect { - cropW := int(float64(srcH) * dstAspect) - cropX := (srcW - cropW) / 2 - cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y) - } else { - cropH := int(float64(srcW) / dstAspect) - cropY := (srcH - cropH) / 2 - cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH) - } - - dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) - xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) - return dst -} - -// decodeTile runs before the processor's size guards apply, so it enforces the caps itself. -func decodeTile(r io.ReadCloser) (image.Image, error) { - data, err := readCapped(r) - if err != nil { - return nil, err - } - img, _, err := decodeCapped(data) - if err != nil { - return nil, err - } - return fillCenter(img, tileSize/2, tileSize/2), nil -} - -func assembleTiles(tiles []image.Image) (io.ReadCloser, error) { - buf := new(bytes.Buffer) - var err error - if len(tiles) == 4 { - rgba := image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}}) - draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src) - draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src) - draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src) - draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src) - err = png.Encode(buf, rgba) - } else { - err = png.Encode(buf, tiles[0]) - } - if err != nil { - return nil, err - } - return io.NopCloser(buf), nil -} diff --git a/core/artwork/processor.go b/core/artwork/processor.go deleted file mode 100644 index 78e625e94..000000000 --- a/core/artwork/processor.go +++ /dev/null @@ -1,356 +0,0 @@ -package artwork - -import ( - "bytes" - "cmp" - "context" - "encoding/base64" - "errors" - "fmt" - "image" - "image/draw" - _ "image/gif" // the only artwork format with no other importer in this package - "io" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork/blurhash" - "github.com/navidrome/navidrome/core/artwork/dominant" - "github.com/navidrome/navidrome/core/artwork/thumbhash" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - xdraw "golang.org/x/image/draw" -) - -// outcome tells the worker whether to delete the queue row (found/absent) or reschedule it. -type outcome int - -const ( - outcomeFound outcome = iota - // outcomeFoundStale: state was written and is served, but a higher-priority external - // source failed, so the row must retry to give it another chance. - outcomeFoundStale - outcomeAbsent - outcomeFailed -) - -func (o outcome) String() string { - switch o { - case outcomeFound: - return "found" - case outcomeFoundStale: - return "foundStale" - case outcomeAbsent: - return "absent" - default: - return "failed" - } -} - -// thumbnailSize is the max dimension fed to both hash encoders; thumbhash rejects anything larger. -const thumbnailSize = 100 - -// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at -// an arbitrarily large endpoint. -func maxImageBytes() int64 { - return parseSize(conf.Server.MaxImageSize, consts.DefaultMaxImageSize) -} - -// maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that -// image.Decode would expand into gigabytes. -const maxImagePixels = 64 << 20 - -// acquired is what a successful acquire persisted, handed back so the caller can warm the resize -// cache without re-reading the rows and the file it just wrote. -type acquired struct { - ia *model.ItemArtwork - mime string - data []byte -} - -// processor turns one queue item into stored artwork; settling the queue row is the Worker's job. -type processor struct { - ds model.DataStore - store *ImageStore - resolver *resolver - pruneLock sync.Locker -} - -// acquire resolves one queue item end to end: find an image, hash/decode/ -// blurhash it, place its bytes, and persist the resulting state. -func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { - repo := p.ds.Artwork() - start := time.Now() - defer func() { - log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID, - "outcome", out, "elapsed", time.Since(start)) - }() - - res, err := p.resolver.resolve(ctx, item) - if err != nil { - traceStage(ctx, "resolve", err) - log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil, 0 - } - if retry, ok := errors.AsType[*agents.RetryLaterError](res.extErr); ok { - retryIn = retry.RetryIn - } - if res.reader == nil { - if res.extErr != nil || res.localError { - // A fault is not a definitive "no image": never settle absent, keep serving old state. - // A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank. - if t := traceFrom(ctx); len(t.Steps()) == 0 { - outcome := OutcomeError - if res.localError { - outcome = OutcomeUnreadable - } - t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome}) - } - log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state", - "kind", item.ItemKind, "id", item.ItemID, "extErr", res.extErr, "localError", res.localError) - return outcomeFailed, nil, retryIn - } - return writeAbsent(ctx, repo, item), nil, 0 - } - defer res.reader.Close() - - readStart := time.Now() - data, err := readCapped(res.reader) - if err != nil { - traceStage(ctx, "read", err) - log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) - return outcomeFailed, nil, retryIn - } - log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID, - "source", res.source, "bytes", len(data), "elapsed", time.Since(readStart)) - - hashStart := time.Now() - hash, err := hashImage(bytes.NewReader(data)) - if err != nil { - traceStage(ctx, "hash", err) - log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil, retryIn - } - log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID, - "hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart)) - - art, err := repo.GetImage(ctx, hash) - switch { - case err == nil && art.Width > 0: - log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind, - "id", item.ItemID, "hash", hash) - // A row with no dimensions was stored when no decoder matched; retry in case one exists now. - case err == nil, errors.Is(err, model.ErrNotFound): - decodeStart := time.Now() - art, err = decodeArtwork(ctx, hash, data) - // Extension-matched local bytes we cannot decode are most likely a codec we lack; an - // external body carries no such guarantee, and empty bytes are no image at all. - if errors.Is(err, image.ErrFormat) && len(data) > 0 && isLocalSource(res.source) { - log.Debug(ctx, "Artwork: No decoder for this image format, storing it without placeholders", - "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data)) - art, err = undecodedArtwork(hash), nil - } - if err != nil { - traceStage(ctx, "decode", err) - log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil, retryIn - } - log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, - "width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart)) - default: - traceStage(ctx, "lookup", err) - log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil, retryIn - } - art.SizeBytes = int64(len(data)) - - ia, err := p.persist(ctx, repo, item, art, res, data) - if err != nil { - traceStage(ctx, "store", err) - log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil, retryIn - } - got = &acquired{ia: ia, mime: art.Mime, data: data} - if res.extErr != nil { - log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure", - "kind", item.ItemKind, "id", item.ItemID, "source", res.source) - return outcomeFoundStale, got, retryIn - } - return outcomeFound, got, retryIn -} - -// persist places the bytes and commits the rows referencing them, excluding Prune for that -// window only so a slow resolution can never hold it off. -func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem, - art *model.Artwork, res resolution, data []byte, -) (*model.ItemArtwork, error) { - if p.pruneLock != nil { - p.pruneLock.Lock() - defer p.pruneLock.Unlock() - } - sourcePath, refMtime, err := placeBytes(p.store, art, res, data) - if err != nil { - return nil, fmt.Errorf("writing image store: %w", err) - } - if err := repo.PutImage(ctx, art); err != nil { - return nil, fmt.Errorf("persisting artwork image: %w", err) - } - ia := &model.ItemArtwork{ - ItemKind: item.ItemKind, - ItemID: item.ItemID, - ImageType: item.ImageType, - Hash: art.Hash, - Source: res.source, - SourcePath: sourcePath, - RefMtime: refMtime, - AttemptedAt: time.Now(), - Trace: traceFrom(ctx).encode(sourcePath), - } - // PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row. - if err := repo.PutItemArtwork(ctx, ia); err != nil { - return nil, fmt.Errorf("persisting item artwork state: %w", err) - } - return ia, nil -} - -// writeAbsent records a known-absent state: every source answered definitively "no". -func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome { - err := repo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: item.ItemKind, - ItemID: item.ItemID, - ImageType: item.ImageType, - AttemptedAt: time.Now(), - Trace: traceFrom(ctx).encode(""), - }) - if err != nil { - log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed - } - log.Debug(ctx, "Artwork: Settled absent, every source answered definitively", - "kind", item.ItemKind, "id", item.ItemID) - return outcomeAbsent -} - -func readCapped(r io.Reader) ([]byte, error) { - limit := maxImageBytes() - data, err := io.ReadAll(io.LimitReader(r, limit+1)) - if err != nil { - return nil, err - } - if int64(len(data)) > limit { - return nil, fmt.Errorf("image exceeds size cap %d", limit) - } - return data, nil -} - -// decodeCapped rejects declared dimensions over maxImagePixels before the full-decode allocation. -func decodeCapped(data []byte) (image.Image, string, error) { - cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) - if err != nil { - return nil, "", fmt.Errorf("decode image config: %w", err) - } - // Compared by division so the cap cannot be defeated by an int64 overflow. - if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxImagePixels/cfg.Height { - return nil, "", fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) - } - img, _, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return nil, "", fmt.Errorf("decode image: %w", err) - } - return img, format, nil -} - -// undecodedArtwork is the row for bytes no decoder matched: servable, but with no dimensions -// and none of the placeholders a decode would have produced. -func undecodedArtwork(hash string) *model.Artwork { - return &model.Artwork{Hash: hash, Mime: mimeForFormat("")} -} - -// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and the two -// placeholder hashes, both encoded from one shared downscaled thumbnail. -func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { - img, format, err := decodeCapped(data) - if err != nil { - return nil, err - } - - thumb := makeThumbnail(img, thumbnailSize) - bh, err := blurhash.Encode(thumb) - if err != nil { - log.Warn(ctx, "Artwork: Blurhash encoding failed", "hash", hash, err) - bh = "" - } - - var th string - if raw, err := thumbhash.Encode(thumb); err != nil { - log.Warn(ctx, "Artwork: Thumbhash encoding failed", "hash", hash, err) - } else { - th = base64.StdEncoding.EncodeToString(raw) - } - - return &model.Artwork{ - Hash: hash, - Mime: mimeForFormat(format), - Width: img.Bounds().Dx(), - Height: img.Bounds().Dy(), - BlurHash: bh, - ThumbHash: th, - DominantColor: dominant.Color(thumb), - }, nil -} - -// makeThumbnail downscales img to fit within maxSize on its longest side; it never upscales. -func makeThumbnail(img image.Image, maxSize int) image.Image { - b := img.Bounds() - w, h := b.Dx(), b.Dy() - if w <= maxSize && h <= maxSize { - return toFastScaleType(img) - } - scale := float64(maxSize) / float64(max(w, h)) - // NRGBA, not RGBA: thumbhash requires straight alpha, and blurhash reads this type without - // converting, so neither encoder allocates a second copy of the thumbnail. - dst := image.NewNRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) - xdraw.CatmullRom.Scale(dst, dst.Bounds(), toFastScaleType(img), b, draw.Src, nil) - return dst -} - -// isFileBacked reports whether the bytes already live in a library/upload file, so the -// content-addressed store must not duplicate them. -func isFileBacked(source string) bool { - return source == "folder" || source == "upload" -} - -// isLocalSource reports whether the bytes came off disk rather than off the network. -func isLocalSource(source string) bool { - return isFileBacked(source) || source == "embedded" -} - -// placeBytes reports the item's backing-file provenance and writes the bytes into the store -// for the sources that have none. -func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) { - if isFileBacked(res.source) { - return res.sourcePath, res.refMtime, nil - } - if res.source == "embedded" { - sourcePath, refMtime = res.sourcePath, res.refMtime - } - return sourcePath, refMtime, store.Write(art.Hash, art.Mime, bytes.NewReader(data)) -} - -// mimeForFormat maps an image.Decode format name to its MIME type; extForMime is the inverse. -func mimeForFormat(format string) string { - switch format { - case "jpeg": - return "image/jpeg" - case "png": - return "image/png" - case "gif": - return "image/gif" - case "webp": - return "image/webp" - } - return "application/octet-stream" -} diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go deleted file mode 100644 index 8eec97cde..000000000 --- a/core/artwork/processor_test.go +++ /dev/null @@ -1,654 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "encoding/binary" - "errors" - "hash/crc32" - "image" - "image/color" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "runtime" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "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" -) - -// jxlCodestream is a JPEG XL bare codestream header: a real image format, with no stdlib decoder. -var jxlCodestream = []byte{0xff, 0x0a, 0x00, 0x10, 0x00} - -// DecodeConfig reads only the header, so the pixel data can be omitted entirely. -func pngHeaderWithDims(w, h uint32) []byte { - ihdr := make([]byte, 13) - binary.BigEndian.PutUint32(ihdr[0:], w) - binary.BigEndian.PutUint32(ihdr[4:], h) - ihdr[8] = 8 // bit depth - ihdr[9] = 2 // color type: truecolor - chunk := append([]byte("IHDR"), ihdr...) - out := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} - out = binary.BigEndian.AppendUint32(out, uint32(len(ihdr))) - out = append(out, chunk...) - return binary.BigEndian.AppendUint32(out, crc32.ChecksumIEEE(chunk)) -} - -var _ = Describe("processor.acquire", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - folderRepo *fakeFolderRepo - libRepo *tests.MockLibraryRepo - ffm *tests.MockFFmpeg - ag *agents.Agents - store *ImageStore - artRepo *tests.MockArtworkRepo - repoRoot string - proc *processor - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - var err error - repoRoot, err = os.Getwd() - Expect(err).ToNot(HaveOccurred()) - - folderRepo = &fakeFolderRepo{} - libRepo = &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - ffm = tests.NewMockFFmpeg("") - ag = agents.GetAgents(&tests.MockDataStore{}, nil) - artRepo = tests.CreateMockArtworkRepo() - ds = &tests.MockDataStore{ - MockedFolder: folderRepo, - MockedLibrary: libRepo, - MockedArtwork: artRepo, - } - ds.MockedAlbum = tests.CreateMockAlbumRepo() - store = NewImageStore(GinkgoT().TempDir()) - proc = &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)} - - conf.Server.CoverArtPriority = "cover.jpg, embedded" - }) - - It("found-folder: persists state from a folder image, writes no store file, keeps sourcePath/refMtime", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, - }) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) - Expect(out).To(Equal(outcomeFound)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).ToNot(BeEmpty()) - Expect(ia.Source).To(Equal("folder")) - Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) - Expect(ia.RefMtime).To(BeNumerically(">", 0)) - - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - // Every placeholder is derived from the one shared thumbnail, so all three land together. - Expect(art.BlurHash).ToNot(BeEmpty()) - Expect(art.ThumbHash).ToNot(BeEmpty()) - Expect(art.DominantColor).To(MatchRegexp(`^#[0-9a-f]{6}$`)) - _, err = store.Open(ia.Hash, art.Mime) - Expect(os.IsNotExist(err)).To(BeTrue(), "folder-backed art must not be duplicated into the store") - }) - - Describe("prune lock scope", func() { - var lock *countingLocker - - BeforeEach(func() { - lock = &countingLocker{} - proc.pruneLock = lock - }) - - It("holds it once across the write window", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alL1", Name: "Album", FolderIDs: []string{"f1"}}, - }) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) - Expect(out).To(Equal(outcomeFound)) - Expect(lock.locks).To(BeNumerically(">", 0), "the write window must exclude prune") - Expect(lock.held()).To(BeFalse(), "the window must close before acquire returns") - }) - - // Resolution can reach the network, so holding the lock across it would let one slow - // provider block prune, and every drain queued behind prune's writer. - It("never takes it while only resolving", func() { - folderRepo.result = nil - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alL2", Name: "Album", FolderIDs: []string{"f1"}}, - }) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) - Expect(out).To(Equal(outcomeAbsent)) - Expect(lock.locks).To(BeZero()) - }) - }) - - It("found-embedded: writes a store file and computes a non-empty blurhash from a real fixture", func() { - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - folderRepo.result = nil - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) - Expect(out).To(Equal(outcomeFound)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("embedded")) - Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) - - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(art.BlurHash).ToNot(BeEmpty()) - - rc, err := store.Open(ia.Hash, art.Mime) - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("absent: no local source and no external error persists a known-absent state", func() { - folderRepo.result = nil - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al3", Name: "Album"}, - }) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) - Expect(out).To(Equal(outcomeAbsent)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(BeEmpty()) - Expect(ia.Source).To(BeEmpty()) - Expect(ia.AttemptedAt).To(BeTemporally("~", time.Now(), time.Second)) - }) - - It("failed-on-unreadable-local: a listed cover that will not open never records absent", func() { - conf.Server.CoverArtPriority = "cover.jpg" - // A folder listing that names a cover the FS will not hand over: a stale mount. - libRoot := GinkgoT().TempDir() - Expect(os.MkdirAll(filepath.Join(libRoot, "an-album"), 0o755)).To(Succeed()) - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jpg"}}} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al-io", Name: "Album", FolderIDs: []string{"f1"}}, - }) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-io", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent") - }) - - // An upload outranks every source, so an unreadable one must not let a lower one take over. - It("failed-on-unreadable-upload: an upload that will not open never records absent", func() { - if runtime.GOOS == "windows" { - // The file would still open, so the spec would pass on the decode error instead. - Skip("chmod does not restrict read access on Windows") - } - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{} - ds.MockedRadio = radioRepo - dir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(dir) - upload := model.UploadedImagePath(consts.EntityRadio, "ra-io.jpg") - Expect(os.MkdirAll(filepath.Dir(upload), 0o755)).To(Succeed()) - Expect(os.WriteFile(upload, []byte("x"), 0o600)).To(Succeed()) - Expect(os.Chmod(upload, 0o000)).To(Succeed()) // present, but unreadable - DeferCleanup(func() { _ = os.Chmod(upload, 0o600) }) - radioRepo.Data["ra-io"] = &model.Radio{ID: "ra-io", Name: "Station", UploadedImage: "ra-io.jpg"} - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra-io", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent") - }) - - // Playlist/radio resolvers walk no chain, so a fault records no step; without a fallback, - // explain would show a give-up with an empty "Gave up after" table. - It("chainless fault: records a fallback trace step naming the faulted source", func() { - if runtime.GOOS == "windows" { - // os.Open under a non-directory maps to a not-exist error on Windows, so no localError. - Skip("cannot provoke an open fault via a non-directory parent on Windows") - } - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{} - ds.MockedRadio = radioRepo - dir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(dir) - upload := model.UploadedImagePath(consts.EntityRadio, "ra-tr.jpg") - // A plain file where the upload's parent should be makes os.Open fault with ENOTDIR, - // deterministically and regardless of the test user's privileges. - Expect(os.MkdirAll(filepath.Dir(filepath.Dir(upload)), 0o755)).To(Succeed()) - Expect(os.WriteFile(filepath.Dir(upload), []byte("x"), 0o600)).To(Succeed()) - radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"} - - trace := &ChainTrace{} - out, _, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"}) - Expect(out).To(Equal(outcomeFailed)) - - steps := trace.Steps() - Expect(steps).To(HaveLen(1), "a radio fault must leave one step so explain is not blank") - Expect(steps[0].Candidate).To(Equal("upload")) - Expect(steps[0].Outcome).To(Equal(OutcomeUnreadable)) - }) - - It("failed-on-extError: leaves the item's state untouched", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al4", Name: "Album"}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - - out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) - Expect(out).To(Equal(outcomeFailed)) - Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay") - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("failed-on-extError: reports the delay a throttled provider asked for", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al4r", Name: "Album"}, - }) - imageAgents(&fakeImageAgent{name: "throttled", err: &agents.RetryLaterError{RetryIn: 42 * time.Second}}) - - out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4r"}) - Expect(out).To(Equal(outcomeFailed)) - Expect(retryIn).To(Equal(42 * time.Second)) - }) - - It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() { - conf.Server.CoverArtPriority = "external, cover.jpg" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) - Expect(out).To(Equal(outcomeFoundStale)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).ToNot(BeEmpty()) - Expect(ia.Source).To(Equal("folder")) - }) - - It("undecodable local file: acquires it anyway, with no placeholder metadata", func() { - libRoot := GinkgoT().TempDir() - Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), jxlCodestream, 0600)).To(Succeed()) - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alU", Name: "Album", FolderIDs: []string{"f1"}}}) - folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"}) - Expect(out).To(Equal(outcomeFound)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alU", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(art.Width).To(BeZero()) - Expect(art.BlurHash).To(BeEmpty()) - }) - - It("empty local file: fails without writing state", func() { - libRoot := GinkgoT().TempDir() - Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), nil, 0600)).To(Succeed()) - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alE", Name: "Album", FolderIDs: []string{"f1"}}}) - folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alE", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - // An agent answering 200 with a non-image body must keep retrying, not pin garbage as a cover. - It("undecodable external body: fails without writing state", func() { - conf.Server.CoverArtPriority = "external" - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("rate limited")) - })) - DeferCleanup(srv.Close) - - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alX", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alX", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("found-external: persists source as external: and stores the fetched bytes", func() { - conf.Server.CoverArtPriority = "external" - imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) - Expect(err).ToNot(HaveOccurred()) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write(imgBytes) - })) - DeferCleanup(srv.Close) - - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alext", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) - Expect(out).To(Equal(outcomeFound)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alext", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("external:deezerFake")) - Expect(ia.Hash).ToNot(BeEmpty()) - - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - rc, err := store.Open(ia.Hash, art.Mime) - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("dedup: a second item with identical bytes skips decode and reuses the artwork row", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al5", Name: "Album A", FolderIDs: []string{"f1"}}, - {ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}}, - }) - - out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) - Expect(out1).To(Equal(outcomeFound)) - ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - - // A re-decode instead of a hash dedup would overwrite this sentinel. - poisoned := artRepo.Data[ia1.Hash] - poisoned.BlurHash = "SENTINEL" - artRepo.Data[ia1.Hash] = poisoned - - out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) - Expect(out2).To(Equal(outcomeFound)) - ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia2.Hash).To(Equal(ia1.Hash)) - - reused, err := artRepo.GetImage(ctx, ia1.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(reused.BlurHash).To(Equal("SENTINEL")) - }) - - It("two items, two files, identical bytes: each item keeps its own provenance; the shared artwork row is written once", func() { - // Byte-identical files share one hash, but provenance is per item. - libRoot := GinkgoT().TempDir() - imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) - Expect(err).ToNot(HaveOccurred()) - for sub, mtime := range map[string]int64{"album-a": 1000, "album-b": 2000} { - dir := filepath.Join(libRoot, sub) - Expect(os.MkdirAll(dir, 0755)).To(Succeed()) - img := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(img, imgBytes, 0600)).To(Succeed()) - Expect(os.Chtimes(img, time.Unix(mtime, 0), time.Unix(mtime, 0))).To(Succeed()) - } - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alA", Name: "Album A", FolderIDs: []string{"fa"}}, - {ID: "alB", Name: "Album B", FolderIDs: []string{"fb"}}, - }) - - folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}} - outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) - Expect(outN).To(Equal(outcomeFound)) - iaA, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(iaA.Source).To(Equal("folder")) - Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) - Expect(iaA.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) - - // A re-decode instead of a hash dedup would overwrite this sentinel. - poisoned := artRepo.Data[iaA.Hash] - poisoned.BlurHash = "SENTINEL" - artRepo.Data[iaA.Hash] = poisoned - - folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}} - outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) - Expect(outN).To(Equal(outcomeFound)) - iaB, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alB", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(iaB.Hash).To(Equal(iaA.Hash)) - Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg")) - Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano())) - - iaAafter, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) - Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) - - Expect(artRepo.Data).To(HaveLen(1)) - reused, err := artRepo.GetImage(ctx, iaA.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(reused.BlurHash).To(Equal("SENTINEL")) - }) - - It("decode failure on found bytes: fails without writing state", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) - imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg") - // Truncated PNG: a known format, so this is a real decode failure, not a missing decoder. - Expect(os.WriteFile(imgPath, pngHeaderWithDims(100, 100), 0600)).To(Succeed()) - - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}} - ds.MockedRadio = radioRepo - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("oversized read: a resolved image larger than the cap fails without writing state", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) - imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg") - f, err := os.Create(imgPath) - Expect(err).ToNot(HaveOccurred()) - Expect(f.Truncate(maxImageBytes() + 1)).To(Succeed()) - Expect(f.Close()).To(Succeed()) - - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}} - ds.MockedRadio = radioRepo - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err = artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "big", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - // The cap is compared by division so an overflowing product cannot slip past it; no supported - // format can declare these dimensions, so this pins the arithmetic, not a live hole. - DescribeTable("rejects out-of-range declared dimensions", - func(w, h uint32) { - _, err := decodeArtwork(ctx, "bomb", pngHeaderWithDims(w, h)) - Expect(err).To(HaveOccurred()) - }, - Entry("both dimensions at the 32-bit maximum", uint32(0xffffffff), uint32(0xffffffff)), - Entry("both dimensions at the signed 32-bit maximum", uint32(0x7fffffff), uint32(0x7fffffff)), - Entry("zero width", uint32(0), uint32(100)), - Entry("zero height", uint32(100), uint32(0)), - ) - - It("decompression bomb: rejects huge declared dimensions before the full decode", func() { - data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap - _, err := decodeArtwork(ctx, "bomb", data) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("dimensions")) - }) - - It("unknown format: reports ErrFormat so the caller can decide", func() { - _, err := decodeArtwork(ctx, "jxl", jxlCodestream) - Expect(err).To(MatchError(image.ErrFormat)) - }) - - It("undecodedArtwork: carries the hash and mime, and nothing a decode would add", func() { - art := undecodedArtwork("jxl") - Expect(art.Hash).To(Equal("jxl")) - Expect(art.Mime).To(Equal("application/octet-stream")) - Expect(art.Width).To(BeZero()) - Expect(art.Height).To(BeZero()) - Expect(art.BlurHash).To(BeEmpty()) - Expect(art.ThumbHash).To(BeEmpty()) - Expect(art.DominantColor).To(BeEmpty()) - }) - - It("corrupt image of a known format: still fails", func() { - data := pngHeaderWithDims(100, 100) // header declares a decodable size, body is missing - _, err := decodeArtwork(ctx, "truncated", data) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("decode image")) - }) - - // Without this a metadata-less row would be reused forever, so a decoder added later - // could never upgrade it. - It("metadata-less row: re-decodes on reuse instead of skipping", func() { - libRoot := GinkgoT().TempDir() - imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) - Expect(err).ToNot(HaveOccurred()) - Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), imgBytes, 0600)).To(Succeed()) - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alM", Name: "Album", FolderIDs: []string{"f1"}}}) - folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - - hash, err := hashImage(bytes.NewReader(imgBytes)) - Expect(err).ToNot(HaveOccurred()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed()) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"}) - Expect(out).To(Equal(outcomeFound)) - - upgraded, err := artRepo.GetImage(ctx, hash) - Expect(err).ToNot(HaveOccurred()) - Expect(upgraded.Width).To(BeNumerically(">", 0)) - Expect(upgraded.BlurHash).ToNot(BeEmpty()) - }) - - It("store write failure: fails without writing state", func() { - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - folderRepo.result = nil - - // A store root that is a plain file makes every MkdirAll under it fail. - blockedRoot := filepath.Join(GinkgoT().TempDir(), "not-a-dir") - Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed()) - proc.store = NewImageStore(blockedRoot) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) -}) - -type countingLocker struct { - locks int - unlocks int -} - -func (l *countingLocker) Lock() { l.locks++ } -func (l *countingLocker) Unlock() { l.unlocks++ } -func (l *countingLocker) held() bool { return l.locks != l.unlocks } - -var _ = Describe("makeThumbnail", func() { - // Both encoders read Pix directly and thumbhash needs straight alpha, so emitting NRGBA is - // what keeps either of them from allocating a converted copy of the thumbnail. - It("emits NRGBA when it downscales", func() { - src := image.NewRGBA(image.Rect(0, 0, 400, 300)) - Expect(makeThumbnail(src, 100)).To(BeAssignableToTypeOf(&image.NRGBA{})) - }) - - It("fits the longest side to maxSize and keeps the aspect ratio", func() { - src := image.NewRGBA(image.Rect(0, 0, 400, 300)) - b := makeThumbnail(src, 100).Bounds() - Expect(b.Dx()).To(Equal(100)) - Expect(b.Dy()).To(Equal(75)) - }) - - It("never upscales an image already within bounds", func() { - src := image.NewRGBA(image.Rect(0, 0, 40, 30)) - Expect(makeThumbnail(src, 100).Bounds()).To(Equal(src.Bounds())) - }) - - // A fully transparent pixel's colour cannot survive any resample, since the scaler works in - // premultiplied space and multiplying by zero is not invertible. Partial alpha is the case - // that distinguishes straight storage from premultiplied. - It("keeps partly transparent colour straight rather than premultiplied", func() { - src := image.NewNRGBA(image.Rect(0, 0, 400, 400)) - for y := range 400 { - for x := range 400 { - src.SetNRGBA(x, y, color.NRGBA{R: 200, G: 40, B: 90, A: 128}) - } - } - thumb, ok := makeThumbnail(src, 100).(*image.NRGBA) - Expect(ok).To(BeTrue()) - // Premultiplied storage would have halved this to ~100. - Expect(thumb.Pix[0]).To(BeNumerically("==", 200)) - Expect(thumb.Pix[3]).To(BeNumerically("==", 128)) - }) -}) - -var _ = Describe("maxImageBytes", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("reads MaxImageSize from config", func() { - conf.Server.MaxImageSize = "30MB" - Expect(maxImageBytes()).To(Equal(int64(30_000_000))) - }) -}) diff --git a/core/artwork/prune.go b/core/artwork/prune.go deleted file mode 100644 index 15b4a5673..000000000 --- a/core/artwork/prune.go +++ /dev/null @@ -1,63 +0,0 @@ -package artwork - -import ( - "context" - "time" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -// pruneMinAge guards the window between artwork insert and item_artwork upsert. -const pruneMinAge = time.Hour - -func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { - start := time.Now() - defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }() - repo := ds.Artwork() - - purged, err := repo.PurgeDanglingItems(ctx) - if err != nil { - return err - } - if purged > 0 { - log.Info(ctx, "Artwork: Purged dangling item state", "count", purged) - } - - // Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed). - queuePurged, err := ds.ArtworkQueue().PurgeDangling(ctx) - if err != nil { - return err - } - if queuePurged > 0 { - log.Info(ctx, "Artwork: Purged dangling queue rows", "count", queuePurged) - } - - // Files younger than the grace window may belong to acquisitions whose rows aren't committed yet. - cutoff := time.Now().Add(-pruneMinAge) - orphans, err := repo.PurgeOrphans(ctx, cutoff) - if err != nil { - return err - } - if orphans > 0 { - log.Info(ctx, "Artwork: Removed orphan image rows", "count", orphans) - } - - // Read after the delete, so the sweep below reclaims the files of the rows just removed. - mimes, err := repo.GetMimeByHash(ctx) - if err != nil { - return err - } - removed, err := store.Sweep(ctx, cutoff, func(hash, ext string) bool { - // A known hash under a stale extension is a superseded mime variant — reclaim it. - m, ok := mimes[hash] - return ok && ext == extForMime(m) - }) - if err != nil { - return err - } - if removed > 0 { - log.Info(ctx, "Artwork: Swept stray files", "count", removed) - } - return nil -} diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go deleted file mode 100644 index 674211dfa..000000000 --- a/core/artwork/prune_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "errors" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type flakyGetArtworkRepo struct { - *tests.MockArtworkRepo -} - -func (f *flakyGetArtworkRepo) GetMimeByHash(context.Context) (map[string]string, error) { - return nil, errors.New("db locked") -} - -var _ = Describe("Prune", func() { - var ctx context.Context - var ds *tests.MockDataStore - var store *ImageStore - var awRepo *tests.MockArtworkRepo - - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = &tests.MockDataStore{} - awRepo = ds.Artwork().(*tests.MockArtworkRepo) - store = NewImageStore(GinkgoT().TempDir()) - }) - - // PutImage refreshes created_at like the SQL repo, so fixtures are aged directly. - ageArtwork := func(h string, t time.Time) { - a := awRepo.Data[h] - a.CreatedAt = t - awRepo.Data[h] = a - } - - It("purges dangling item_artwork state for gone entities, summed across kinds", func() { - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) - awRepo.ExistingIDs = map[string]map[string]bool{ - "al": {}, - "ar": {"live-artist": true}, - } - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := awRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "live-artist", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - }) - - It("purges dangling artwork_queue rows for gone entities", func() { - queueRepo := tests.CreateMockArtworkQueueRepo() - Expect(queueRepo.Enqueue(ctx, - model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary}, - model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary}, - )).To(Succeed()) - queueRepo.ExistingIDs = map[string]map[string]bool{"al": {"live-album": true}} - ds.MockedArtworkQueue = queueRepo - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - Expect(findQueued(queueRepo, "al", "gone-album")).To(BeNil()) - Expect(findQueued(queueRepo, "al", "live-album")).ToNot(BeNil()) - }) - - It("deletes orphan rows and their store files, keeps referenced ones", func() { - data := []byte("orphan-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) - ageArtwork(h, old) - - kept := []byte("kept-bytes") - hk, _ := hashImage(bytes.NewReader(kept)) - Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := awRepo.GetImage(ctx, h) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = store.Open(h, "image/jpeg") - Expect(os.IsNotExist(err)).To(BeTrue()) - rc, err := store.Open(hk, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("spares an aged row that item_artwork state still references", func() { - data := []byte("reacquired-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) - ageArtwork(h, time.Now().Add(-2*time.Hour)) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "a1", - ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := awRepo.GetImage(ctx, h) - Expect(err).ToNot(HaveOccurred()) - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("spares an unreferenced row recreated inside the grace window", func() { - data := []byte("fresh-reacquired-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - // Reacquisition refreshed created_at, so the row is unreferenced but too young to drop. - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := awRepo.GetImage(ctx, h) - Expect(err).ToNot(HaveOccurred()) - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("spares an orphan file freshly touched by an overlapping acquisition", func() { - data := []byte("racing-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) - ageArtwork(h, time.Now().Add(-2*time.Hour)) - // The row is orphaned, but a concurrent acquisition just touched the file's mtime. - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("sweeps store files that have no artwork row", func() { - stray := []byte("no-row-bytes") - h, _ := hashImage(bytes.NewReader(stray)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(stray))).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := store.Open(h, "image/jpeg") - Expect(os.IsNotExist(err)).To(BeTrue()) - }) - - It("sweeps an obsolete mime variant of a reacquired hash", func() { - data := []byte("variant-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) - Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) - // The row records the current mime; the .png file is a superseded variant. - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := store.Open(h, "image/png") - Expect(os.IsNotExist(err)).To(BeTrue()) - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("reclaims the other orphan files when one of them cannot be removed", func() { - tests.SkipOnWindows("uses Unix file permission bits") - if os.Geteuid() == 0 { - Skip("read-only dir cannot block root (e.g. tests in a container)") - } - old := time.Now().Add(-2 * time.Hour) - - blocked := []byte("blocked-bytes") - hb, _ := hashImage(bytes.NewReader(blocked)) - Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed()) - Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed()) - ageArtwork(hb, old) - - good := []byte("good-bytes") - hg, _ := hashImage(bytes.NewReader(good)) - Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed()) - Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed()) - ageArtwork(hg, old) - - // A read-only shard directory makes os.Remove fail (EACCES) for hb's file only. - shardDir := filepath.Dir(store.path(hb, "image/jpeg")) - Expect(shardDir).ToNot(Equal(filepath.Dir(store.path(hg, "image/jpeg"))), "fixtures must land in different shards") - Expect(os.Chmod(shardDir, 0500)).To(Succeed()) - DeferCleanup(func() { _ = os.Chmod(shardDir, 0755) }) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := awRepo.GetImage(ctx, hg) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = store.Open(hg, "image/jpeg") - Expect(os.IsNotExist(err)).To(BeTrue()) - - // The row purge does not depend on file removal, so only the file survives. - _, err = awRepo.GetImage(ctx, hb) - Expect(err).To(MatchError(model.ErrNotFound)) - rc, err := store.Open(hb, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("never sweeps files on a transient DB error", func() { - ds.MockedArtwork = &flakyGetArtworkRepo{MockArtworkRepo: tests.CreateMockArtworkRepo()} - - data := []byte("live-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).ToNot(Succeed()) - - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) -}) diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go new file mode 100644 index 000000000..73ba9b5ee --- /dev/null +++ b/core/artwork/reader_album.go @@ -0,0 +1,206 @@ +package artwork + +import ( + "cmp" + "context" + "crypto/md5" + "errors" + "fmt" + "io" + "path" + "slices" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/natural" +) + +type albumArtworkReader struct { + cacheKey + a *artwork + provider external.Provider + album model.Album + updatedAt *time.Time + imgFiles []string // library-relative, forward-slash, no leading slash + lib libraryView +} + +func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) { + al, err := artwork.ds.Album(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + _, imgFiles, imagesUpdateAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) + if err != nil { + return nil, err + } + lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID) + if err != nil { + return nil, err + } + a := &albumArtworkReader{ + a: artwork, + provider: provider, + album: *al, + updatedAt: imagesUpdateAt, + imgFiles: imgFiles, + lib: lib, + } + a.cacheKey.artID = artID + a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) + if imagesUpdateAt != nil { + a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt) + } + return a, nil +} + +func (a *albumArtworkReader) Key() string { + hashInput := conf.Server.CoverArtPriority + if conf.Server.EnableExternalServices { + hashInput = conf.Server.Agents + hashInput + } + hash := md5.Sum([]byte(hashInput)) + return fmt.Sprintf( + "%s.%x.%t", + a.cacheKey.Key(), + hash, + conf.Server.EnableExternalServices, + ) +} +func (a *albumArtworkReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority) + return selectImageReader(ctx, a.artID, ff...) +} + +func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { + var ff []sourceFunc + for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "embedded": + embedRel := a.album.EmbedArtPath + ff = append(ff, + fromTag(ctx, a.lib.FS, embedRel), + fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)), + ) + case pattern == "external": + ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider)) + case len(a.imgFiles) > 0: + ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern)) + } + } + return ff +} + +func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...model.Album) ([]string, []string, *time.Time, error) { + var folderIDs []string + for _, album := range albums { + folderIDs = append(folderIDs, album.FolderIDs...) + } + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}}) + if err != nil { + return nil, nil, nil, err + } + + folderIDSet := make(map[string]bool, len(folderIDs)) + for _, id := range folderIDs { + folderIDSet[id] = true + } + + // Check if all folders share a common parent that is not already included. + // This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg" + // when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/"). + // For single-folder albums, the parent is only included when the folder has no + // images of its own (indicating a disc subfolder needing parent artwork). + if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" { + if len(folders) >= 2 || !anyFolderHasImages(folders) { + parentFolder, err := ds.Folder(ctx).Get(commonParentID) + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) + } else if err != nil { + return nil, nil, nil, err + } + if parentFolder != nil && parentFolder.ParentID != "" { + folders = append(folders, *parentFolder) + } + } + } + + var paths []string + var imgFiles []string + var updatedAt time.Time + for _, f := range folders { + paths = append(paths, f.AbsolutePath()) + if f.ImagesUpdatedAt.After(updatedAt) { + updatedAt = f.ImagesUpdatedAt + } + rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") + for _, img := range f.ImageFiles { + imgFiles = append(imgFiles, path.Join(rel, img)) + } + } + + // Sort image files to ensure consistent selection of cover art + // This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg) + // by comparing base filenames without extensions + slices.SortFunc(imgFiles, compareImageFiles) + + return paths, imgFiles, &updatedAt, nil +} + +func anyFolderHasImages(folders []model.Folder) bool { + for _, f := range folders { + if len(f.ImageFiles) > 0 { + return true + } + } + return false +} + +// commonParentFolder returns the shared parent folder ID when all folders have the +// same parent and that parent is not already in folderIDSet. Returns "" otherwise. +func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string { + if len(folders) == 0 { + return "" + } + parentID := folders[0].ParentID + if parentID == "" || folderIDSet[parentID] { + return "" + } + for _, f := range folders[1:] { + if f.ParentID != parentID { + return "" + } + } + return parentID +} + +// compareImageFiles sorts image paths by: base filename (natural order), +// then path depth (shallower first), then full path (stable tiebreaker). +func compareImageFiles(a, b string) int { + // Case-insensitive comparison + a = strings.ToLower(a) + b = strings.ToLower(b) + + // Extract base filenames without extensions + baseA := strings.TrimSuffix(path.Base(a), path.Ext(a)) + baseB := strings.TrimSuffix(path.Base(b), path.Ext(b)) + + // Compare base names first, then prefer shallower paths, then full path as tiebreaker + return cmp.Or( + natural.Compare(baseA, baseB), + cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")), + natural.Compare(a, b), + ) +} diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go new file mode 100644 index 000000000..1cf039bee --- /dev/null +++ b/core/artwork/reader_album_test.go @@ -0,0 +1,399 @@ +package artwork + +import ( + "context" + "errors" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Album Artwork Reader", func() { + Describe("loadAlbumFoldersPaths", func() { + var ( + ctx context.Context + ds *fakeDataStore + repo *fakeFolderRepo + album model.Album + now time.Time + expectedAt time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + now = time.Now().Truncate(time.Second) + expectedAt = now.Add(5 * time.Minute) + + // Set up the test folders with image files + repo = &fakeFolderRepo{} + ds = &fakeDataStore{ + folderRepo: repo, + } + album = model.Album{ + ID: "album1", + Name: "Album", + FolderIDs: []string{"folder1", "folder2", "folder3"}, + } + }) + + It("returns sorted image files", func() { + repo.result = []model.Folder{ + { + Path: "Artist/Album/Disc1", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"}, + }, + { + Path: "Artist/Album/Disc2", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + Path: "Artist/Album/Disc10", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + + // Check that image files are sorted by base name (without extension) + Expect(imgFiles).To(HaveLen(5)) + + // Files should be sorted by base filename without extension, then by full path + // "back" < "cover", so back.jpg comes first + // Then all cover.jpg files, sorted by path + Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg")) + Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg")) + Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg")) + }) + + It("prioritizes files without numeric suffixes", func() { + // Test case for issue #4683: cover.jpg should come before cover.1.jpg + repo.result = []model.Folder{ + { + Path: "Artist/Album", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(3)) + + // cover.jpg should come first because "cover" < "cover.1" < "cover.2" + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg")) + }) + + It("handles case-insensitive sorting", func() { + // Test that Cover.jpg and cover.jpg are treated as equivalent + repo.result = []model.Folder{ + { + Path: "Artist/Album", + ImagesUpdatedAt: now, + ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(3)) + + // Files should be sorted case-insensitively: BACK, cover, Folder + Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg")) + }) + + It("includes images from parent folder for multi-disc albums", func() { + // Simulates: Artist/Album/cover.jpg with tracks in Artist/Album/CD1/ and Artist/Album/CD2/ + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "CD1", + ParentID: "parentFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + { + ID: "folder2", + Path: "Artist/Album", + Name: "CD2", + ParentID: "parentFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "parentFolder", + Path: "Artist", + Name: "Album", + ParentID: "artistFolder", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg", "back.jpg"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(2)) + Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) + }) + + It("does not query parent when parent ID is already in album folders", func() { + // When the parent folder is already one of the album's folders, skip it + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist", + Name: "Album", + ParentID: "folder2", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: "", + Name: "Artist", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + // Get should not have been called (parent already in folder set) + Expect(repo.getCallCount).To(Equal(0)) + }) + + It("does not query parent when folders have different parents", func() { + // When album folders span different parents, don't search any parent + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist1/Album", + Name: "part1", + ParentID: "parentA", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: "Artist2/Album", + Name: "part2", + ParentID: "parentB", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg")) + // Get should not have been called (different parents) + Expect(repo.getCallCount).To(Equal(0)) + }) + + It("does not include library root parent for multi-folder albums", func() { + // Two album parts directly under the library root — parent is the root itself + repo.result = []model.Folder{ + { + ID: "folder1", + Path: ".", + Name: "AlbumPart1", + ParentID: "rootFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: ".", + Name: "AlbumPart2", + ParentID: "rootFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "rootFolder", + Path: "", + Name: ".", + ParentID: "", + ImageFiles: []string{"unrelated.jpg"}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("includes top-level album folder for multi-disc albums", func() { + // Album folder directly under library root, with disc subfolders + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Album", + Name: "Disc1", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"folder.jpg"}, + }, + { + ID: "folder2", + Path: "Album", + Name: "Disc2", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"folder.jpg"}, + }, + } + repo.parentResult = &model.Folder{ + ID: "albumFolder", + Path: ".", + Name: "Album", + ParentID: "rootFolder", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(3)) + Expect(imgFiles[0]).To(Equal("Album/cover.jpg")) + Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg")) + Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("does not query parent for single-folder albums that already have images", func() { + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist", + Name: "Album", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + Expect(repo.getCallCount).To(Equal(0)) + }) + + It("includes parent images for single-disc-subfolder albums", 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"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("propagates non-ErrNotFound errors from parent folder lookup", func() { + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "CD1", + ParentID: "parentFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: "Artist/Album", + Name: "CD2", + ParentID: "parentFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.getErr = errors.New("db connection failed") + + _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).To(MatchError("db connection failed")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("continues gracefully when parent folder is not found", func() { + // Parent folder may have been deleted; should log a warning and continue + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "CD1", + ParentID: "missingParent", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: "Artist/Album", + Name: "CD2", + ParentID: "missingParent", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + // parentResult is nil, so Get will return ErrNotFound + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + }) +}) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go new file mode 100644 index 000000000..37b7b6dee --- /dev/null +++ b/core/artwork/reader_artist.go @@ -0,0 +1,311 @@ +package artwork + +import ( + "context" + "crypto/md5" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/str" +) + +const ( + // maxArtistFolderTraversalDepth defines how many directory levels to search + // when looking for artist images (artist folder + parent directories) + maxArtistFolderTraversalDepth = 3 +) + +type artistReader struct { + cacheKey + a *artwork + provider external.Provider + artist model.Artist + artistFolder string + imgFiles []string + imgFolderImgPath string // cached path from ArtistImageFolder lookup + lib libraryView +} + +func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { + ar, err := artwork.ds.Artist(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + // Only consider albums where the artist is the sole album artist. + als, err := artwork.ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"album_artist_id": artID.ID}, + squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1}, + }, + }) + if err != nil { + return nil, err + } + albumPaths, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, als...) + if err != nil { + return nil, err + } + artistFolder, artistFolderLastUpdate, err := loadArtistFolder(ctx, artwork.ds, als, albumPaths) + if err != nil { + return nil, err + } + var lib libraryView + if len(als) > 0 { + lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID) + if err != nil { + return nil, err + } + } + a := &artistReader{ + a: artwork, + provider: provider, + artist: *ar, + artistFolder: artistFolder, + imgFiles: imgFiles, + lib: lib, + } + // TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can + // change _after_ retrieving from external sources, making the key invalid + //a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt + + a.cacheKey.lastUpdate = *imagesUpdatedAt + if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = *ar.UpdatedAt + } + if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = artistFolderLastUpdate + } + if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") { + a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name) + if a.imgFolderImgPath != "" { + if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = info.ModTime() + } + } + } + a.cacheKey.artID = artID + return a, nil +} + +func (a *artistReader) Key() string { + hash := md5.Sum([]byte(conf.Server.Agents)) + return fmt.Sprintf( + "%s.%t.%x", + a.cacheKey.Key(), + conf.Server.EnableExternalServices, + hash, + ) +} + +func (a *artistReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + ff := []sourceFunc{a.fromArtistUploadedImage()} + ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...) + return selectImageReader(ctx, a.artID, ff...) +} + +func (a *artistReader) fromArtistUploadedImage() sourceFunc { + return fromLocalFile(a.artist.UploadedImagePath()) +} + +func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc { + var ff []sourceFunc + for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "external": + ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider)) + case pattern == "image-folder": + ff = append(ff, a.fromArtistImageFolder(ctx)) + case strings.HasPrefix(pattern, "album/"): + if a.lib.FS != nil { + ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) + } + default: + ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern)) + } + } + return ff +} + +// fromArtistFolder walks up from artistFolder toward libPath looking for a +// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth +// and the library root: once we reach libPath (or if artistFolder is outside +// libPath), the walk stops. All reads go through libFS, which keeps artwork +// resolution scoped to the configured library. +func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc { + return func() (io.ReadCloser, string, error) { + if libFS == nil { + return nil, "", fmt.Errorf("artist folder lookup unavailable") + } + rel, err := filepath.Rel(libPath, artistFolder) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath) + } + // fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may + // return backslash separators on Windows. + rel = filepath.ToSlash(rel) + current := artistFolder + for range maxArtistFolderTraversalDepth { + reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern) + if err == nil { + return reader, hit, nil + } + if rel == "." { + break // reached library root; don't traverse above it + } + rel = path.Dir(rel) + current = filepath.Dir(current) + } + return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder) + } +} + +// findImageInFolder globs libFS at relFolder for pattern and returns the first +// matching image. absFolder is used only for the returned display path and log +// messages so callers see absolute-looking paths consistent with the rest of +// the artwork pipeline. +func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) { + log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder) + globPattern := pattern + if relFolder != "." { + globPattern = path.Join(escapeGlobLiteral(relFolder), pattern) + } + matches, err := fs.Glob(libFS, globPattern) + if err != nil { + log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err) + return nil, "", err + } + + // Filter to valid image files + var imagePaths []string + for _, m := range matches { + if !model.IsImageFile(m) { + continue + } + imagePaths = append(imagePaths, m) + } + + // Sort image files by prioritizing base filenames without numeric + // suffixes (e.g., artist.jpg before artist.1.jpg) + slices.SortFunc(imagePaths, compareImageFiles) + + for _, p := range imagePaths { + f, err := libFS.Open(p) + if err != nil { + log.Warn(ctx, "Could not open cover art file", "file", p, err) + continue + } + _, name := path.Split(p) + return f, filepath.Join(absFolder, name), nil + } + + return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder) +} + +func escapeGlobLiteral(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '\\', '*', '?', '[', ']': + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} + +func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { + if len(albums) == 0 { + return "", time.Time{}, nil + } + libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries + + folderPath := str.LongestCommonPrefix(paths) + if !strings.HasSuffix(folderPath, string(filepath.Separator)) { + folderPath, _ = filepath.Split(folderPath) + } + folderPath = filepath.Dir(folderPath) + + // Manipulate the path to get the folder ID + // TODO: This is a bit hacky, but it's the easiest way to get the folder ID, ATM + libPath := core.AbsolutePath(ctx, ds, libID, "") + folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath) + + log.Trace(ctx, "Calculating artist folder details", "folderPath", folderPath, "folderID", folderID, + "libPath", libPath, "libID", libID, "albumPaths", paths) + + // Get the last update time for the folder + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}}) + if err != nil || len(folders) == 0 { + log.Warn(ctx, "Could not find folder for artist", "folderPath", folderPath, "id", folderID, + "libPath", libPath, "libID", libID, err) + return "", time.Time{}, err + } + return folderPath, folders[0].ImagesUpdatedAt, nil +} + +func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc { + return func() (io.ReadCloser, string, error) { + folder := conf.Server.ArtistImageFolder + if folder == "" { + return nil, "", nil + } + // Use cached path from newArtistArtworkReader if available, + // avoiding a second directory scan. + path := a.imgFolderImgPath + if path == "" { + path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name) + } + if path == "" { + return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder) + } + f, err := os.Open(path) + if err != nil { + return nil, "", err + } + return f, path, nil + } +} + +// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name +// (case-insensitive). Returns the full path, or empty string if not found. +func findImageInArtistFolder(folder, mbzArtistID, artistName string) string { + entries, err := os.ReadDir(folder) + if err != nil { + return "" + } + for _, candidate := range []string{mbzArtistID, artistName} { + if candidate == "" { + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + base := strings.TrimSuffix(name, filepath.Ext(name)) + if strings.EqualFold(base, candidate) && model.IsImageFile(name) { + return filepath.Join(folder, name) + } + } + } + return "" +} diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go new file mode 100644 index 000000000..e2a1f2094 --- /dev/null +++ b/core/artwork/reader_artist_test.go @@ -0,0 +1,740 @@ +package artwork + +import ( + "context" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("artistArtworkReader", func() { + var _ = Describe("loadArtistFolder", func() { + var ( + ctx context.Context + fds *fakeDataStore + repo *fakeFolderRepo + albums model.Albums + paths []string + now time.Time + expectedUpdTime time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + DeferCleanup(stubCoreAbsolutePath()) + + now = time.Now().Truncate(time.Second) + expectedUpdTime = now.Add(5 * time.Minute) + repo = &fakeFolderRepo{ + result: []model.Folder{ + { + ImagesUpdatedAt: expectedUpdTime, + }, + }, + err: nil, + } + fds = &fakeDataStore{ + folderRepo: repo, + } + albums = model.Albums{ + {LibraryID: 1, ID: "album1", Name: "Album 1"}, + } + }) + + When("no albums provided", func() { + It("returns empty and zero time", func() { + folder, upd, err := loadArtistFolder(ctx, fds, model.Albums{}, []string{"/dummy/path"}) + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(BeEmpty()) + Expect(upd).To(BeZero()) + }) + }) + + When("artist has only one album", func() { + It("returns the parent folder", func() { + paths = []string{ + filepath.FromSlash("/music/artist/album1"), + } + folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) + Expect(upd).To(Equal(expectedUpdTime)) + }) + }) + + When("the artist have multiple albums", func() { + It("returns the common prefix for the albums paths", func() { + paths = []string{ + filepath.FromSlash("/music/library/artist/one"), + filepath.FromSlash("/music/library/artist/two"), + } + folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/library/artist"))) + Expect(upd).To(Equal(expectedUpdTime)) + }) + }) + + When("the album paths contain same prefix", func() { + It("returns the common prefix", func() { + paths = []string{ + filepath.FromSlash("/music/artist/album1"), + filepath.FromSlash("/music/artist/album2"), + } + folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) + Expect(upd).To(Equal(expectedUpdTime)) + }) + }) + + When("ds.Folder().GetAll returns an error", func() { + It("returns an error", func() { + paths = []string{ + filepath.FromSlash("/music/artist/album1"), + filepath.FromSlash("/music/artist/album2"), + } + repo.err = errors.New("fake error") + folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) + Expect(err).To(MatchError(ContainSubstring("fake error"))) + // Folder and time are empty on error. + Expect(folder).To(BeEmpty()) + Expect(upd).To(BeZero()) + }) + }) + }) + + var _ = Describe("fromArtistFolder", func() { + var ( + ctx context.Context + tempDir string + libFS fs.FS + testFunc sourceFunc + ) + + BeforeEach(func() { + ctx = context.Background() + tempDir = GinkgoT().TempDir() + libFS = os.DirFS(tempDir) + }) + + When("artist folder contains matching image", func() { + BeforeEach(func() { + // Create test structure: /temp/artist/artist.jpg + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + artistImagePath := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("finds and returns the image", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist.jpg")) + + // Verify we can read the content + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("fake image data")) + reader.Close() + }) + }) + + When("artist folder name contains glob metacharacters", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "Artist [Live]") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + artistImagePath := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("treats the folder path literally when globbing through the library fs", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("bracketed artist image")) + reader.Close() + }) + }) + + When("artist folder is empty but parent contains image", func() { + BeforeEach(func() { + // Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/ + parentDir := filepath.Join(tempDir, "parent") + artistDir := filepath.Join(parentDir, "artist") + albumDir := filepath.Join(artistDir, "album") + Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) + + // Put artist image in parent directory + artistImagePath := filepath.Join(parentDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("finds image in parent directory", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("parent image")) + reader.Close() + }) + }) + + When("image is two levels up", func() { + BeforeEach(func() { + // Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/ + grandparentDir := filepath.Join(tempDir, "grandparent") + parentDir := filepath.Join(grandparentDir, "parent") + artistDir := filepath.Join(parentDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Put artist image in grandparent directory + artistImagePath := filepath.Join(grandparentDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("finds image in grandparent directory", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("grandparent image")) + reader.Close() + }) + }) + + When("images exist at multiple levels", func() { + BeforeEach(func() { + // Create test structure with images at multiple levels + grandparentDir := filepath.Join(tempDir, "grandparent") + parentDir := filepath.Join(grandparentDir, "parent") + artistDir := filepath.Join(parentDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Put artist images at all levels + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("prioritizes the closest (artist folder) image", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("artist level")) + reader.Close() + }) + }) + + When("pattern matches multiple files", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create multiple matching files + Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("returns the first valid image file in sorted order", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + + // Should return an image file, + // Files are sorted: jpg comes before png alphabetically. + // .abc comes first, but it's not an image. + Expect(path).To(ContainSubstring("artist.jpg")) + reader.Close() + }) + }) + + When("prioritizing files without numeric suffixes", func() { + BeforeEach(func() { + // Test case for issue #4683: artist.jpg should come before artist.1.jpg + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create multiple matches with and without numeric suffixes + Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist.jpg")) + + // Verify it's the main file, not a numbered variant + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("artist main")) + reader.Close() + }) + }) + + When("handling case-insensitive sorting", func() { + BeforeEach(func() { + // Test case to ensure case-insensitive natural sorting + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create files with mixed case names + Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*") + }) + + It("sorts case-insensitively", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + + // Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder") + Expect(path).To(ContainSubstring("artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("artist")) + reader.Close() + }) + }) + + When("no matching files exist anywhere", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create non-matching files + Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("returns an error", func() { + reader, path, err := testFunc() + Expect(err).To(HaveOccurred()) + Expect(reader).To(BeNil()) + Expect(path).To(BeEmpty()) + Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'")) + Expect(err.Error()).To(ContainSubstring("parent directories")) + }) + }) + + When("directory traversal reaches filesystem root", func() { + BeforeEach(func() { + // Start from a shallow directory to test root boundary + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("handles root boundary gracefully", func() { + reader, path, err := testFunc() + Expect(err).To(HaveOccurred()) + Expect(reader).To(BeNil()) + Expect(path).To(BeEmpty()) + // Should not panic or cause infinite loop + }) + }) + + When("file exists but cannot be opened", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create a file that cannot be opened (permission denied) + restrictedFile := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("logs warning and continues searching", func() { + // This test depends on the ability to restrict file permissions + // For now, we'll just ensure it doesn't panic and returns appropriate error + reader, _, err := testFunc() + // The file should be readable in test environment, so this will succeed + // In a real scenario with permission issues, it would continue searching + if err == nil { + Expect(reader).ToNot(BeNil()) + reader.Close() + } + }) + }) + + When("single album artist scenario (original issue)", func() { + BeforeEach(func() { + // Simulate the exact folder structure from the issue: + // /music/artist/album1/ (single album) + // /music/artist/artist.jpg (artist image that should be found) + artistDir := filepath.Join(tempDir, "music", "artist") + albumDir := filepath.Join(artistDir, "album1") + Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) + + // Create artist.jpg in the artist folder (this was not being found before) + artistImagePath := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed()) + + // The fromArtistFolder is called with the artist folder path + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("finds artist.jpg in artist folder for single album artist", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist.jpg")) + Expect(path).To(ContainSubstring("artist")) + + // Verify the content + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("single album artist image")) + reader.Close() + }) + }) + }) + + Describe("fromArtistUploadedImage", func() { + var ( + tempDir string + reader *artistReader + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + conf.Server.DataFolder = tempDir + + // Create the artwork/artist directory + Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed()) + + reader = &artistReader{} + }) + + When("artist has an uploaded image", func() { + It("returns the uploaded image", func() { + imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) + + reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"} + sf := reader.fromArtistUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("uploaded artist image")) + r.Close() + }) + }) + + When("artist has no uploaded image", func() { + It("returns nil reader (falls through)", func() { + reader.artist = model.Artist{ID: "ar-1"} + sf := reader.fromArtistUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + }) + + Describe("fromArtistImageFolder", func() { + var ( + ctx context.Context + tempDir string + ar *artistReader + ) + + BeforeEach(func() { + ctx = context.Background() + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + ar = &artistReader{} + }) + + When("ArtistImageFolder is not configured", func() { + It("returns nil (skips)", func() { + conf.Server.ArtistImageFolder = "" + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + + When("image exists matching MBID", func() { + It("finds the image by MBID", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + imgPath := filepath.Join(tempDir, mbid+".jpg") + Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("mbid image")) + r.Close() + }) + }) + + When("MBID match is case-insensitive", func() { + It("finds the image regardless of case", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E" + imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png") + Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("no MBID file exists but artist name file does", func() { + It("falls back to artist name match", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "Test Artist.jpg") + Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("name image")) + r.Close() + }) + }) + + When("artist name match is case-insensitive", func() { + It("matches regardless of case", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "test artist.jpg") + Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("both MBID and name files exist", func() { + It("prefers MBID over name match", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + mbidPath := filepath.Join(tempDir, mbid+".jpg") + namePath := filepath.Join(tempDir, "Test Artist.jpg") + Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed()) + Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(mbidPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("mbid image")) + r.Close() + }) + }) + + When("no matching image found", func() { + It("returns an error", func() { + conf.Server.ArtistImageFolder = tempDir + // Create an unrelated file + Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, _, err := sf() + Expect(err).To(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("no image found")) + }) + }) + + When("cached imgFolderImgPath is set", func() { + It("uses cached path instead of scanning", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "cached.jpg") + Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + ar.imgFolderImgPath = imgPath + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("cached image")) + r.Close() + }) + }) + }) + + Describe("findImageInArtistFolder", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + }) + + When("matching file exists by MBID", func() { + It("returns the file path", func() { + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + imgPath := filepath.Join(tempDir, mbid+".jpg") + Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) + + path := findImageInArtistFolder(tempDir, mbid, "Test") + Expect(path).To(Equal(imgPath)) + }) + }) + + When("matching file exists by name", func() { + It("returns the file path", func() { + imgPath := filepath.Join(tempDir, "Test Artist.png") + Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) + + path := findImageInArtistFolder(tempDir, "", "Test Artist") + Expect(path).To(Equal(imgPath)) + }) + }) + + When("no matching file exists", func() { + It("returns empty string", func() { + path := findImageInArtistFolder(tempDir, "", "Unknown Artist") + Expect(path).To(BeEmpty()) + }) + }) + + When("folder does not exist", func() { + It("returns empty string", func() { + path := findImageInArtistFolder("/nonexistent/path", "", "Test") + Expect(path).To(BeEmpty()) + }) + }) + }) +}) + +type fakeFolderRepo struct { + model.FolderRepository + result []model.Folder + parentResult *model.Folder + getErr error + getCallCount int + err error +} + +func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { + return f.result, f.err +} + +func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) { + f.getCallCount++ + if f.getErr != nil { + return nil, f.getErr + } + if f.parentResult != nil { + return f.parentResult, nil + } + return nil, model.ErrNotFound +} + +type fakeDataStore struct { + model.DataStore + folderRepo *fakeFolderRepo +} + +func (fds *fakeDataStore) Folder(_ context.Context) model.FolderRepository { + return fds.folderRepo +} + +func stubCoreAbsolutePath() func() { + // Override core.AbsolutePath to return a fixed string during tests. + original := core.AbsolutePath + core.AbsolutePath = func(_ context.Context, ds model.DataStore, libID int, p string) string { + return filepath.FromSlash("/music") + } + return func() { + core.AbsolutePath = original + } +} diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go new file mode 100644 index 000000000..0f648c987 --- /dev/null +++ b/core/artwork/reader_disc.go @@ -0,0 +1,267 @@ +package artwork + +import ( + "context" + "crypto/md5" + "fmt" + "io" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" +) + +type discArtworkReader struct { + cacheKey + a *artwork + album model.Album + discNumber int + imgFiles []string // library-relative, forward-slash, no leading slash + discFoldersRel map[string]bool // library-relative folder paths + isMultiFolder bool + firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs + lib libraryView + updatedAt *time.Time +} + +func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*discArtworkReader, error) { + albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID) + if err != nil { + return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err) + } + + al, err := a.ds.Album(ctx).Get(albumID) + if err != nil { + return nil, err + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, a.ds, *al) + if err != nil { + return nil, err + } + + // Query mediafiles for this album + disc to find folder associations and first track + mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Sort: "track_number", + Order: "ASC", + Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber}, + }) + if err != nil { + return nil, err + } + + lib, err := loadLibraryView(ctx, a.ds, al.LibraryID) + if err != nil { + return nil, err + } + + // Build disc folder set and find first track. mf.Path is already library-relative. + var firstTrackRel string + allFolderIDs := make(map[string]bool) + for _, mf := range mfs { + allFolderIDs[mf.FolderID] = true + if firstTrackRel == "" { + firstTrackRel = filepath.ToSlash(mf.Path) + } + } + + // Resolve folder IDs to library-relative paths + discFoldersRel := make(map[string]bool) + if len(allFolderIDs) > 0 { + folderIDs := make([]string, 0, len(allFolderIDs)) + for id := range allFolderIDs { + folderIDs = append(folderIDs, id) + } + folders, err := a.ds.Folder(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"folder.id": folderIDs}, + }) + if err != nil { + return nil, err + } + for _, f := range folders { + rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") + discFoldersRel[rel] = true + } + } + + isMultiFolder := len(al.FolderIDs) > 1 + + r := &discArtworkReader{ + a: a, + album: *al, + discNumber: discNumber, + imgFiles: imgFiles, + discFoldersRel: discFoldersRel, + isMultiFolder: isMultiFolder, + firstTrackRel: firstTrackRel, + lib: lib, + updatedAt: imagesUpdatedAt, + } + r.cacheKey.artID = artID + r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) + if imagesUpdatedAt != nil { + r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt) + } + return r, nil +} + +func (d *discArtworkReader) Key() string { + hash := md5.Sum([]byte(conf.Server.DiscArtPriority)) + return fmt.Sprintf( + "%s.%x", + d.cacheKey.Key(), + hash, + ) +} + +func (d *discArtworkReader) LastUpdated() time.Time { + return d.lastUpdate +} + +func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + var ff = d.fromDiscArtPriority(ctx, d.a.ffmpeg, conf.Server.DiscArtPriority) + // Fallback to album cover art + albumArtID := model.NewArtworkID(model.KindAlbumArtwork, d.album.ID, &d.album.UpdatedAt) + ff = append(ff, fromAlbum(ctx, d.a, albumArtID)) + return selectImageReader(ctx, d.cacheKey.artID, ff...) +} + +func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { + var ff []sourceFunc + for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "embedded": + ff = append(ff, + fromTag(ctx, d.lib.FS, d.firstTrackRel), + fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)), + ) + case pattern == "external": + // Not supported for disc art, silently ignore + case pattern == "discsubtitle": + if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" { + ff = append(ff, d.fromDiscSubtitle(ctx, subtitle)) + } + case len(d.imgFiles) > 0: + ff = append(ff, d.fromExternalFile(ctx, pattern)) + } + } + return ff +} + +// fromDiscSubtitle returns a sourceFunc that matches image files whose stem +// (filename without extension) equals the disc subtitle (case-insensitive). +func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc { + return func() (io.ReadCloser, string, error) { + for _, file := range d.imgFiles { + name := path.Base(file) + stem := strings.TrimSuffix(name, path.Ext(name)) + if !strings.EqualFold(stem, subtitle) { + continue + } + f, err := d.lib.FS.Open(file) + if err != nil { + log.Warn(ctx, "Could not open disc art file", "file", file, err) + continue + } + return f, file, nil + } + return nil, "", fmt.Errorf("disc %d: no image file matching subtitle %q", d.discNumber, subtitle) + } +} + +// globMetaChars holds the substitution metacharacters understood by +// filepath.Match. The '\' escape character is intentionally excluded: +// disc art patterns come from user config and never include escaped +// metachars in practice, and treating '\' as a metachar would misalign +// the literal-prefix extraction in extractDiscNumber. +const globMetaChars = "*?[" + +// extractDiscNumber parses the disc number from a filename matched by a +// filepath.Match-style glob pattern. +// +// Both pattern and filename must already be lowercased by the caller, which +// is also expected to have verified that filepath.Match(pattern, filename) +// is true before calling this function. +func extractDiscNumber(pattern, filename string) (int, bool) { + metaIdx := strings.IndexAny(pattern, globMetaChars) + if metaIdx < 0 { + return 0, false + } + prefix := pattern[:metaIdx] + if !strings.HasPrefix(filename, prefix) { + return 0, false + } + + start := len(prefix) + end := start + for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' { + end++ + } + if end == start { + return 0, false + } + num, err := strconv.Atoi(filename[start:end]) + if err != nil { + return 0, false + } + return num, true +} + +// fromExternalFile returns a sourceFunc that matches image files against a glob +// pattern. A numbered filename whose number equals the target disc wins over +// any unnumbered candidate; callers must pass a lowercase pattern. +func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc { + isLiteral := !strings.ContainsAny(pattern, globMetaChars) + return func() (io.ReadCloser, string, error) { + var fallbacks []string + for _, file := range d.imgFiles { + name := strings.ToLower(path.Base(file)) + match, err := filepath.Match(pattern, name) + if err != nil { + log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file) + continue + } + if !match { + continue + } + + if !isLiteral { + if num, hasNum := extractDiscNumber(pattern, name); hasNum { + if num != d.discNumber { + continue + } + f, err := d.lib.FS.Open(file) + if err != nil { + log.Warn(ctx, "Could not open disc art file", "file", file, err) + continue + } + return f, file, nil + } + } + + if d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] { + continue + } + fallbacks = append(fallbacks, file) + } + + for _, file := range fallbacks { + f, err := d.lib.FS.Open(file) + if err != nil { + log.Warn(ctx, "Could not open disc art file", "file", file, err) + continue + } + return f, file, nil + } + return nil, "", fmt.Errorf("disc %d: pattern '%s' not matched by files", d.discNumber, pattern) + } +} diff --git a/core/artwork/disc_test.go b/core/artwork/reader_disc_test.go similarity index 75% rename from core/artwork/disc_test.go rename to core/artwork/reader_disc_test.go index 8852741cf..8264ee27b 100644 --- a/core/artwork/disc_test.go +++ b/core/artwork/reader_disc_test.go @@ -6,8 +6,6 @@ import ( "path/filepath" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -181,19 +179,19 @@ var _ = Describe("Disc Artwork Reader", func() { lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } - cc := reader.discCandidates(ctx, nil, "disc*.*, cover.*") - Expect(cc).To(HaveLen(2)) - res, ok := cc[0].resolve() - Expect(ok).To(BeTrue()) - Expect(res.sourcePath).To(Equal(reader.lib.Abs(f2))) - res.reader.Close() + ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*") + Expect(ff).To(HaveLen(2)) + r, path, err := ff[0]() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(f2)) + r.Close() - cc = reader.discCandidates(ctx, nil, "cover.*, disc*.*") - Expect(cc).To(HaveLen(2)) - res, ok = cc[0].resolve() - Expect(ok).To(BeTrue()) - Expect(res.sourcePath).To(Equal(reader.lib.Abs(f1))) - res.reader.Close() + ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*") + Expect(ff).To(HaveLen(2)) + r, path, err = ff[0]() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(f1)) + r.Close() }) DescribeTable("numbered match wins over shared fallback within a pattern", @@ -430,109 +428,64 @@ var _ = Describe("Disc Artwork Reader", func() { }) Describe("discArtworkReader", func() { - var ( - reader *discArtworkReader - tmpDir string - ) - - BeforeEach(func() { - tmpDir = GinkgoT().TempDir() - reader = &discArtworkReader{ - discNumber: 2, - isMultiFolder: true, - discFoldersRel: map[string]bool{"music/album/cd2": true}, - imgFiles: []string{ - "music/album/cd1/disc.jpg", - "music/album/cd2/disc.jpg", - "music/album/cd2/disc2.jpg", - }, - firstTrackRel: "music/album/cd2/track1.flac", - lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, - } - }) - - Describe("selectImage", func() { - It("abandons the walk when the context is cancelled", func() { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - res, err := reader.selectImage(ctx, nil, "disc*.*, cover.*", &chainState{}) - - Expect(err).To(MatchError(context.Canceled)) - Expect(res.reader).To(BeNil()) - }) - - // "the track has no embedded art" and "the track is there but unreadable" are the two - // answers a wrong-artwork report needs told apart; only the second is worth retrying. - It("reports a track it cannot parse as unreadable, not as a miss", func() { - trace := &ChainTrace{} - track := filepath.Join(tmpDir, filepath.FromSlash(reader.firstTrackRel)) - Expect(os.MkdirAll(filepath.Dir(track), 0755)).To(Succeed()) - Expect(os.WriteFile(track, []byte("not audio"), 0600)).To(Succeed()) - - res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded", - &chainState{trace: trace}) - - Expect(err).ToNot(HaveOccurred()) - Expect(res.localError).To(BeTrue()) - Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeUnreadable}})) - }) - - It("reports a disc with no tracks to read as a miss", func() { - trace := &ChainTrace{} - reader.firstTrackRel = "" - - res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded", - &chainState{trace: trace}) - - Expect(err).ToNot(HaveOccurred()) - Expect(res.localError).To(BeFalse(), "there was nothing to read, so nothing failed to read") - Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeMiss}})) - }) - }) - - Describe("discCandidates", func() { - It("returns a resolvable candidate for glob patterns", func() { - cc := reader.discCandidates(context.Background(), nil, "disc*.*") - Expect(cc).To(HaveLen(1)) - Expect(cc[0].resolve).ToNot(BeNil()) - }) - - It("returns one candidate per entry, in order", func() { - cc := reader.discCandidates(context.Background(), nil, "disc*.*, cd*.*, embedded") - Expect(slice.Map(cc, func(c discCandidate) string { return c.pattern })). - To(Equal([]string{"disc*.*", "cd*.*", "embedded"})) - }) - - It("skips an empty entry rather than building a glob that matches nothing", func() { - cc := reader.discCandidates(context.Background(), nil, "disc*.*,") - Expect(cc).To(HaveLen(1)) - }) - - // The skip reasons below are what `artwork explain` prints, so an entry that maps to no - // source must say why instead of vanishing from the walk. - DescribeTable("keeps an entry that maps to no source, with its reason", - func(setup func(), priority, reason string) { - setup() - cc := reader.discCandidates(context.Background(), nil, priority) - Expect(cc).To(HaveLen(1)) - Expect(cc[0].resolve).To(BeNil()) - Expect(cc[0].skip).To(Equal(reason)) - }, - Entry("external is unsupported", func() {}, "external", - "external sources are not supported for disc artwork"), - Entry("no images in the album folder", func() { reader.imgFiles = nil }, "disc*.*", - "no images in album folder"), - Entry("the disc has no subtitle", - func() { reader.album = model.Album{Discs: model.Discs{2: ""}} }, "discsubtitle", - "disc has no subtitle"), + Describe("fromDiscArtPriority", func() { + var ( + reader *discArtworkReader + tmpDir string ) + BeforeEach(func() { + tmpDir = GinkgoT().TempDir() + reader = &discArtworkReader{ + discNumber: 2, + isMultiFolder: true, + discFoldersRel: map[string]bool{"music/album/cd2": true}, + imgFiles: []string{ + "music/album/cd1/disc.jpg", + "music/album/cd2/disc.jpg", + "music/album/cd2/disc2.jpg", + }, + firstTrackRel: "music/album/cd2/track1.flac", + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, + } + }) + + It("returns source funcs for glob patterns", func() { + ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*") + Expect(ff).To(HaveLen(1)) + }) + + It("returns source funcs for embedded pattern", func() { + ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded") + Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag + }) + + It("handles multiple comma-separated patterns", func() { + ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded") + Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag + }) + + It("ignores 'external' pattern silently", func() { + ff := reader.fromDiscArtPriority(context.Background(), nil, "external") + Expect(ff).To(HaveLen(0)) + }) + + It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() { + reader.imgFiles = nil + ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*") + Expect(ff).To(HaveLen(0)) + }) + It("returns source func for discsubtitle pattern", func() { reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}} - cc := reader.discCandidates(context.Background(), nil, "discsubtitle") - Expect(cc).To(HaveLen(1)) - Expect(cc[0].resolve).ToNot(BeNil()) + ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle") + Expect(ff).To(HaveLen(1)) + }) + + It("returns no source func for discsubtitle when disc has no subtitle", func() { + reader.album = model.Album{Discs: model.Discs{2: ""}} + ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle") + Expect(ff).To(HaveLen(0)) }) }) }) diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go new file mode 100644 index 000000000..eac3c5e70 --- /dev/null +++ b/core/artwork/reader_mediafile.go @@ -0,0 +1,82 @@ +package artwork + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" +) + +type mediafileArtworkReader struct { + cacheKey + a *artwork + mediafile model.MediaFile + album model.Album + lib libraryView +} + +func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) { + mf, err := artwork.ds.MediaFile(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + al, err := artwork.ds.Album(ctx).Get(mf.AlbumID) + if err != nil { + return nil, err + } + _, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) + if err != nil { + return nil, err + } + lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID) + if err != nil { + return nil, err + } + a := &mediafileArtworkReader{ + a: artwork, + mediafile: *mf, + album: *al, + lib: lib, + } + a.cacheKey.artID = artID + a.cacheKey.lastUpdate = mf.UpdatedAt + if al.UpdatedAt.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = al.UpdatedAt + } + if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = *imagesUpdatedAt + } + return a, nil +} + +func (a *mediafileArtworkReader) Key() string { + return fmt.Sprintf( + "%s.%t", + a.cacheKey.Key(), + conf.Server.EnableMediaFileCoverArt, + ) +} +func (a *mediafileArtworkReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + var ff []sourceFunc + if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork { + ff = []sourceFunc{ + fromTag(ctx, a.lib.FS, a.mediafile.Path), + fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)), + } + } + // For multi-disc albums, fall back to disc artwork first; for single-disc albums, + // skip disc resolution (it would just fall through to album art anyway). + if len(a.album.Discs) > 1 { + ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID())) + } else { + ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID())) + } + return selectImageReader(ctx, a.artID, ff...) +} diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go new file mode 100644 index 000000000..09707843d --- /dev/null +++ b/core/artwork/reader_playlist.go @@ -0,0 +1,269 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "image" + "image/draw" + "image/png" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + xdraw "golang.org/x/image/draw" +) + +type playlistArtworkReader struct { + cacheKey + a *artwork + pl model.Playlist +} + +const tileSize = 600 + +func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*playlistArtworkReader, error) { + pl, err := artwork.ds.Playlist(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + a := &playlistArtworkReader{ + a: artwork, + pl: *pl, + } + a.cacheKey.artID = artID + a.cacheKey.lastUpdate = pl.UpdatedAt + + // Check sidecar and ExternalImageURL local file ModTimes for cache invalidation. + // If either is newer than the playlist's UpdatedAt, use that instead so the + // cache is busted when a user replaces a sidecar image or local file reference. + for _, path := range []string{ + findPlaylistSidecarPath(ctx, pl.Path), + pl.ExternalImageURL, + } { + if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + continue + } + if info, err := os.Stat(path); err == nil { + if info.ModTime().After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = info.ModTime() + } + } + } + + return a, nil +} + +func (a *playlistArtworkReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + return selectImageReader(ctx, a.artID, + a.fromPlaylistUploadedImage(), + a.fromPlaylistSidecar(ctx), + a.fromPlaylistExternalImage(ctx), + a.fromGeneratedTiledCover(ctx), + fromAlbumPlaceholder(), + ) +} + +func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc { + return fromLocalFile(a.pl.UploadedImagePath()) +} + +func (a *playlistArtworkReader) fromPlaylistSidecar(ctx context.Context) sourceFunc { + return fromLocalFile(findPlaylistSidecarPath(ctx, a.pl.Path)) +} + +func (a *playlistArtworkReader) fromPlaylistExternalImage(ctx context.Context) sourceFunc { + return func() (io.ReadCloser, string, error) { + imgURL := a.pl.ExternalImageURL + if imgURL == "" { + return nil, "", nil + } + parsed, err := url.Parse(imgURL) + if err != nil { + return nil, "", err + } + if parsed.Scheme == "http" || parsed.Scheme == "https" { + if !conf.Server.EnableM3UExternalAlbumArt { + return nil, "", nil + } + return fromURL(ctx, parsed) + } + return fromLocalFile(imgURL)() + } +} + +// fromLocalFile returns a sourceFunc that opens the given local path. +// Returns (nil, "", nil) if path is empty — signalling "not found, try next source". +func fromLocalFile(path string) sourceFunc { + return func() (io.ReadCloser, string, error) { + if path == "" { + return nil, "", nil + } + f, err := os.Open(path) + if err != nil { + return nil, "", err + } + return f, path, nil + } +} + +// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar +// image file with the same base name (case-insensitive). Returns empty string if +// no matching image is found or if plsPath is empty. +func findPlaylistSidecarPath(ctx context.Context, plsPath string) string { + if plsPath == "" { + return "" + } + dir := filepath.Dir(plsPath) + base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath)) + + entries, err := os.ReadDir(dir) + if err != nil { + log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err) + return "" + } + for _, entry := range entries { + name := entry.Name() + nameBase := strings.TrimSuffix(name, filepath.Ext(name)) + if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) { + return filepath.Join(dir, name) + } + } + return "" +} + +func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc { + return func() (io.ReadCloser, string, error) { + tiles, err := a.loadTiles(ctx) + if err != nil { + return nil, "", err + } + r, err := a.createTiledImage(ctx, tiles) + return r, "", err + } +} + +func toAlbumArtworkIDs(albumIDs []string) []model.ArtworkID { + return slice.Map(albumIDs, func(id string) model.ArtworkID { + al := model.Album{ID: id} + return al.CoverArtID() + }) +} + +func (a *playlistArtworkReader) loadTiles(ctx context.Context) ([]image.Image, error) { + tracksRepo := a.a.ds.Playlist(ctx).Tracks(a.pl.ID, false) + albumIds, err := tracksRepo.GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) + if err != nil { + log.Error(ctx, "Error getting album IDs for playlist", "id", a.pl.ID, "name", a.pl.Name, err) + return nil, err + } + ids := toAlbumArtworkIDs(albumIds) + + var tiles []image.Image + for _, id := range ids { + r, _, err := fromAlbum(ctx, a.a, id)() + if err == nil { + tile, err := a.createTile(ctx, r) + if err == nil { + tiles = append(tiles, tile) + } + _ = r.Close() + } + if len(tiles) == 4 { + break + } + } + switch len(tiles) { + case 0: + return nil, errors.New("could not find any eligible cover") + case 2: + tiles = append(tiles, tiles[1], tiles[0]) + case 3: + tiles = append(tiles, tiles[0]) + } + return tiles, nil +} + +func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) (image.Image, error) { + img, _, err := image.Decode(r) + if err != nil { + return nil, err + } + return fillCenter(img, tileSize/2, tileSize/2), nil +} + +func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) { + buf := new(bytes.Buffer) + var rgba draw.Image + var err error + if len(tiles) == 4 { + rgba = image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}}) + draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src) + draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src) + draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src) + draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src) + err = png.Encode(buf, rgba) + } else { + err = png.Encode(buf, tiles[0]) + } + if err != nil { + return nil, err + } + return io.NopCloser(buf), nil +} + +func rect(pos int) image.Rectangle { + r := image.Rectangle{} + switch pos { + case 1: + r.Min.X = tileSize / 2 + case 2: + r.Min.Y = tileSize / 2 + case 3: + r.Min.X = tileSize / 2 + r.Min.Y = tileSize / 2 + } + r.Max.X = r.Min.X + tileSize/2 + r.Max.Y = r.Min.Y + tileSize/2 + return r +} + +// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly, +// equivalent to imaging.Fill with Center anchor. +func fillCenter(src image.Image, dstW, dstH int) image.Image { + srcBounds := src.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + // Calculate crop rectangle (center crop to match destination aspect ratio) + srcAspect := float64(srcW) / float64(srcH) + dstAspect := float64(dstW) / float64(dstH) + + var cropRect image.Rectangle + if srcAspect > dstAspect { + // Source is wider — crop horizontally + cropW := int(float64(srcH) * dstAspect) + cropX := (srcW - cropW) / 2 + cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y) + } else { + // Source is taller — crop vertically + cropH := int(float64(srcW) / dstAspect) + cropY := (srcH - cropH) / 2 + cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH) + } + + dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) + return dst +} diff --git a/core/artwork/reader_radio.go b/core/artwork/reader_radio.go new file mode 100644 index 000000000..22db6e302 --- /dev/null +++ b/core/artwork/reader_radio.go @@ -0,0 +1,40 @@ +package artwork + +import ( + "context" + "io" + "time" + + "github.com/navidrome/navidrome/model" +) + +type radioArtworkReader struct { + cacheKey + a *artwork + radio model.Radio +} + +func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) { + r, err := artwork.ds.Radio(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + a := &radioArtworkReader{a: artwork, radio: *r} + a.cacheKey.artID = artID + a.cacheKey.lastUpdate = r.UpdatedAt + return a, nil +} + +func (a *radioArtworkReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + return selectImageReader(ctx, a.artID, + a.fromRadioUploadedImage(), + ) +} + +func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc { + return fromLocalFile(a.radio.UploadedImagePath()) +} diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go new file mode 100644 index 000000000..1f5bc9084 --- /dev/null +++ b/core/artwork/reader_radio_test.go @@ -0,0 +1,84 @@ +package artwork + +import ( + "context" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("radioArtworkReader", func() { + var ( + tempDir string + reader *radioArtworkReader + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + conf.Server.DataFolder = tempDir + + Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed()) + + reader = &radioArtworkReader{} + }) + + Describe("fromRadioUploadedImage", func() { + When("radio has an uploaded image", func() { + It("returns the uploaded image", func() { + imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) + + reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} + sf := reader.fromRadioUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("radio has no uploaded image", func() { + It("returns nil reader (falls through)", func() { + reader.radio = model.Radio{ID: "rd-1"} + sf := reader.fromRadioUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + }) + + Describe("Reader", func() { + When("radio has an uploaded image", func() { + It("returns the image reader", func() { + imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) + + reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} + reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} + r, _, err := reader.Reader(context.Background()) + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + }) + }) + + When("radio has no uploaded image", func() { + It("returns ErrUnavailable", func() { + reader.radio = model.Radio{ID: "rd-1"} + reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} + r, _, err := reader.Reader(context.Background()) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(r).To(BeNil()) + }) + }) + }) +}) diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go new file mode 100644 index 000000000..85a19a4c3 --- /dev/null +++ b/core/artwork/reader_resized.go @@ -0,0 +1,186 @@ +package artwork + +import ( + "bytes" + "context" + "fmt" + "image" + "image/draw" + "image/jpeg" + "image/png" + "io" + "sync" + "time" + + "github.com/gen2brain/webp" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + xdraw "golang.org/x/image/draw" +) + +func init() { + conf.AddHook(func() { + if err := webp.Dynamic(); err != nil { + log.Debug("Using WASM WebP encoder/decoder", "reason", err) + } else { + log.Debug("Using native libwebp for WebP encoding/decoding") + } + }) +} + +var bufPool = sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, +} + +type resizedArtworkReader struct { + artID model.ArtworkID + cacheKey string + lastUpdate time.Time + size int + square bool + a *artwork +} + +func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) { + r := &resizedArtworkReader{a: a} + r.artID = artID + r.size = size + r.square = square + + // Get lastUpdated and cacheKey from original artwork + original, err := a.getArtworkReader(ctx, artID, 0, false) + if err != nil { + return nil, err + } + r.cacheKey = original.Key() + r.lastUpdate = original.LastUpdated() + return r, nil +} + +func (a *resizedArtworkReader) Key() string { + baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size) + if a.square { + return baseKey + ".square" + } + return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverArtQuality) +} + +func (a *resizedArtworkReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + // Get artwork in original size, possibly from cache + orig, _, err := a.a.Get(ctx, a.artID, 0, false) + if err != nil { + return nil, "", err + } + defer orig.Close() + + resized, origSize, err := a.resizeImage(ctx, orig) + if resized == nil { + log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square) + } else { + log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square) + } + if err != nil { + log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, "square", a.square, err) + } + if err != nil || resized == nil { + // if we couldn't resize the image, return the original + orig, _, err = a.a.Get(ctx, a.artID, 0, false) + return orig, "", err + } + // Preserve ReadCloser semantics if the resized reader already supports Close + // (e.g., ffmpeg pipe), otherwise wrap with NopCloser + if rc, ok := resized.(io.ReadCloser); ok { + return rc, fmt.Sprintf("%s@%d", a.artID, a.size), nil + } + return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil +} + +func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader) (io.Reader, int, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, 0, fmt.Errorf("reading image data: %w", err) + } + + // Preserve animation for animated images + if isAnimatedGIF(data) { + if a.a.ffmpeg.IsAvailable() { + // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) + r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality) + if err == nil { + return r, 0, nil + } + log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) + } + } else if isAnimatedWebP(data) || isAnimatedPNG(data) { + // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) + return bytes.NewReader(data), 0, nil + } + + return resizeStaticImage(data, a.size, a.square) +} + +func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { + original, format, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, 0, err + } + + bounds := original.Bounds() + originalSize := max(bounds.Max.X, bounds.Max.Y) + + // Clamp size to original dimensions - upscaling wastes resources and adds no information + if size > originalSize { + size = originalSize + } + + if originalSize <= size && !square { + return nil, originalSize, nil + } + + // Calculate aspect-fit dimensions + srcW, srcH := bounds.Dx(), bounds.Dy() + scale := float64(size) / float64(max(srcW, srcH)) + dstW := int(float64(srcW) * scale) + dstH := int(float64(srcH) * scale) + + var dst *image.NRGBA + var dstRect image.Rectangle + if square { + // Square canvas with image centered (transparent padding via zero-initialized NRGBA) + dst = image.NewNRGBA(image.Rect(0, 0, size, size)) + offsetX := (size - dstW) / 2 + offsetY := (size - dstH) / 2 + dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) + } else { + // Tight-fit canvas + dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + dstRect = dst.Bounds() + } + xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) + + buf := bufPool.Get().(*bytes.Buffer) + buf.Reset() + if conf.Server.EnableWebPEncoding { + err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) + } else if format == "png" || square { + err = png.Encode(buf, dst) + } else { + err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) + } + if err != nil { + bufPool.Put(buf) + return nil, originalSize, err + } + // Copy bytes before returning buffer to pool (pool may reuse the buffer) + encoded := make([]byte, buf.Len()) + copy(encoded, buf.Bytes()) + bufPool.Put(buf) + return bytes.NewReader(encoded), originalSize, nil +} diff --git a/core/artwork/reader_resized_test.go b/core/artwork/reader_resized_test.go new file mode 100644 index 000000000..7c14f5e44 --- /dev/null +++ b/core/artwork/reader_resized_test.go @@ -0,0 +1,176 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "io" + + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("resizeImage", func() { + var mockFF *tests.MockFFmpeg + var r *resizedArtworkReader + + BeforeEach(func() { + mockFF = tests.NewMockFFmpeg("converted-animated-data") + r = &resizedArtworkReader{ + size: 300, + square: false, + a: &artwork{ffmpeg: mockFF}, + } + }) + + Describe("animated GIF handling", func() { + It("converts animated GIF via ffmpeg when available", func() { + data := createAnimatedGIF(3) + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should have been processed by ffmpeg (mock returns "converted-animated-data") + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) // MockFFmpeg echoes input back + }) + + It("falls back to static resize when ffmpeg fails for animated GIF", func() { + mockFF.Error = errors.New("ffmpeg failed") + // Use size smaller than image so static resize actually produces output + r.size = 1 + data := createAnimatedGIF(3) + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + // Should fall through to static resize successfully (no ffmpeg error propagated) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Verify it's a static image (WebP encoded), not the ffmpeg error + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(len(output)).To(BeNumerically(">", 0)) + }) + + It("preserves animation for square thumbnails with animated GIF", func() { + r.square = true + data := createAnimatedGIF(3) + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should have been processed by ffmpeg (mock returns input data) + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) + }) + }) + + Describe("animated WebP handling", func() { + It("returns animated WebP data as-is when not square", func() { + data := createAnimatedWebPBytes() + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should return original data unchanged + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) + }) + + It("preserves animated WebP for square thumbnails", func() { + r.square = true + data := createAnimatedWebPBytes() + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should return original data unchanged + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) + }) + }) + + Describe("animated PNG handling", func() { + It("returns animated PNG data as-is when not square", func() { + data := createAPNGBytes() + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should return original data unchanged + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) + }) + + It("preserves animated PNG for square thumbnails", func() { + r.square = true + data := createAPNGBytes() + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + // Should return original data unchanged + output, err := io.ReadAll(result) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal(data)) + }) + }) + + Describe("static image handling", func() { + It("resizes a static PNG normally", func() { + data := createStaticPNGBytes() + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + // Static PNG is 2x2, size 300 is larger, so should return nil (no upscale) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) + + Describe("ReadCloser preservation", func() { + It("preserves Close semantics from ffmpeg ReadCloser", func() { + // Create a trackable ReadCloser + tracker := &closeTracker{Reader: bytes.NewReader([]byte("test data"))} + mockFF2 := &mockFFmpegWithCloser{tracker: tracker} + r.a = &artwork{ffmpeg: mockFF2} + + data := createAnimatedGIF(3) + result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + + // The result should be an io.ReadCloser (the tracker) + rc, ok := result.(io.ReadCloser) + Expect(ok).To(BeTrue()) + Expect(rc.Close()).ToNot(HaveOccurred()) + Expect(tracker.closed).To(BeTrue()) + }) + }) +}) + +// closeTracker is an io.ReadCloser that tracks whether Close was called. +type closeTracker struct { + io.Reader + closed bool +} + +func (c *closeTracker) Close() error { + c.closed = true + return nil +} + +// mockFFmpegWithCloser is a minimal FFmpeg mock that returns a specific ReadCloser +// for ConvertAnimatedImage, allowing us to verify Close propagation. +type mockFFmpegWithCloser struct { + ffmpeg.FFmpeg + tracker *closeTracker +} + +func (m *mockFFmpegWithCloser) IsAvailable() bool { return true } +func (m *mockFFmpegWithCloser) ConvertAnimatedImage(_ context.Context, _ io.Reader, _ int, _ int) (io.ReadCloser, error) { + return m.tracker, nil +} diff --git a/core/artwork/resize.go b/core/artwork/resize.go deleted file mode 100644 index 9b3ff46e5..000000000 --- a/core/artwork/resize.go +++ /dev/null @@ -1,144 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "fmt" - "image" - "image/draw" - "image/jpeg" - "image/png" - "io" - "sync" - "time" - - "github.com/gen2brain/webp" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/log" - xdraw "golang.org/x/image/draw" -) - -func init() { - conf.AddHook(func() { - // gen2brain/webp picks native vs WASM in its own init(), with no way to switch at - // runtime: 32-bit builds need the "nodynamic" tag (see Dockerfile) to force WASM. - if err := webp.Dynamic(); err != nil { - log.Debug("Artwork: Using WASM WebP encoder/decoder", "reason", err) - } else { - log.Debug("Artwork: Using native libwebp for WebP encoding/decoding") - } - }) -} - -var bufPool = sync.Pool{ - New: func() any { - return new(bytes.Buffer) - }, -} - -// resizeImageData resizes raw image bytes to fit size, preserving animation where -// possible. A nil reader means the image was already within bounds (no resize needed). -func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size int, square bool) (io.Reader, int, error) { - start := time.Now() - defer func() { - log.Trace(ctx, "Artwork: Resized image", "bytes", len(data), "size", size, "square", square, - "elapsed", time.Since(start)) - }() - if isAnimatedGIF(data) { - if ffm.IsAvailable() { - // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) - r, err := ffm.ConvertAnimatedImage(ctx, bytes.NewReader(data), size, conf.Server.CoverArtQuality) - if err == nil { - return r, 0, nil - } - log.Warn(ctx, "Artwork: Could not convert animated GIF, falling back to static", err) - } - } else if isAnimatedWebP(data) || isAnimatedPNG(data) { - // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) - return bytes.NewReader(data), 0, nil - } - - return resizeStaticImage(data, size, square) -} - -// toFastScaleType converts types x/image/draw has no optimized scaler for (e.g. *image.NYCbCrA, -// *image.Paletted) to *image.RGBA, avoiding CatmullRom.Scale's generic per-pixel fallback. -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 := decodeCapped(data) - if err != nil { - return nil, 0, err - } - - bounds := original.Bounds() - originalSize := max(bounds.Max.X, bounds.Max.Y) - - // Clamp size to original dimensions - upscaling wastes resources and adds no information - if size > originalSize { - size = originalSize - } - - if originalSize <= size && !square { - return nil, originalSize, nil - } - - // Calculate aspect-fit dimensions - srcW, srcH := bounds.Dx(), bounds.Dy() - scale := float64(size) / float64(max(srcW, srcH)) - dstW := int(float64(srcW) * scale) - dstH := int(float64(srcH) * scale) - - var dst *image.NRGBA - var dstRect image.Rectangle - if square { - // Square canvas with image centered (transparent padding via zero-initialized NRGBA) - dst = image.NewNRGBA(image.Rect(0, 0, size, size)) - offsetX := (size - dstW) / 2 - offsetY := (size - dstH) / 2 - dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) - } else { - // Tight-fit canvas - 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) - buf.Reset() - if conf.Server.EnableWebPEncoding { - err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) - } else if format == "png" || square { - err = png.Encode(buf, dst) - } else { - err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) - } - if err != nil { - bufPool.Put(buf) - return nil, originalSize, err - } - // Copy bytes before returning buffer to pool (pool may reuse the buffer) - encoded := bytes.Clone(buf.Bytes()) - bufPool.Put(buf) - return bytes.NewReader(encoded), originalSize, nil -} - -// formatQualityTag folds the encoder config (WebP toggle + quality) into a cache-key -// fragment, so flipping either setting invalidates previously-encoded sized artwork. -func formatQualityTag() string { - if conf.Server.EnableWebPEncoding { - return fmt.Sprintf("webp%d", conf.Server.CoverArtQuality) - } - return fmt.Sprintf("q%d", conf.Server.CoverArtQuality) -} diff --git a/core/artwork/resize_test.go b/core/artwork/resize_test.go deleted file mode 100644 index 76b95974d..000000000 --- a/core/artwork/resize_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package artwork - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("resizeStaticImage", func() { - It("rejects images whose declared dimensions exceed the pixel cap before decoding", func() { - _, _, err := resizeStaticImage(pngHeaderWithDims(9000, 9000), 300, false) - Expect(err).To(MatchError(ContainSubstring("exceed pixel cap"))) - }) -}) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go deleted file mode 100644 index fb07332fe..000000000 --- a/core/artwork/resolve.go +++ /dev/null @@ -1,608 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "fmt" - "image" - "io" - "io/fs" - "net/url" - "os" - "path" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" -) - -// resolution is one attempted acquisition outcome for an entity. -type resolution struct { - reader io.ReadCloser // nil when no source yielded an image - source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated" - sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise - refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath - // a faulted external source, carrying the provider's requested delay when it named one. - // With no reader it forces failed (never absent); on a hit, serve this but retry later. - extErr error - // a local source that should have been readable wasn't. With no reader it forces failed, - // so a transient I/O fault never records absent. - localError bool -} - -// chainState carries what a priority walk has seen so far. A hit takes extErr with it so a -// transient external failure still retries; localErr is dropped, as the scanner re-lists changes. -type chainState struct { - extErr error - localErr bool - trace *ChainTrace // nil only where no caller attached one -} - -// try stamps the accumulated external failure onto a hit, and records the miss otherwise. -func (c *chainState) try(candidate string, res resolution, ok bool) (resolution, bool) { - if ok { - res.extErr = c.extErr - c.record(candidate, OutcomeHit, res.sourcePath) - return res, true - } - c.localErr = c.localErr || res.localError - if res.localError { - c.record(candidate, OutcomeUnreadable, "") - } else { - c.record(candidate, OutcomeMiss, "") - } - return resolution{}, false -} - -func (c *chainState) record(candidate string, out Outcome, detail string) { - c.trace.add(TraceStep{Candidate: candidate, Outcome: out, Detail: detail}) -} - -// exhausted is the outcome when no source in the chain yielded an image. -func (c *chainState) exhausted() resolution { - return resolution{extErr: c.extErr, localError: c.localErr} -} - -// externalSource holds the agents to ask and the rate limiter/circuit breaker to ask them through. -type externalSource struct { - agents *agents.Agents - gate gateFunc -} - -// resolver walks a kind's priority chain and returns the first hit; a nil ext means local-only. -type resolver struct { - ds model.DataStore - ffmpeg ffmpeg.FFmpeg - ext *externalSource -} - -func newResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, gate gateFunc) *resolver { - if gate == nil { - gate = passthroughGate - } - return &resolver{ds: ds, ffmpeg: ffm, ext: &externalSource{agents: ag, gate: gate}} -} - -// newLocalResolver builds a resolver that can neither reach the network nor sample album art -// for the worker-built grid. -func newLocalResolver(ds model.DataStore, ffm ffmpeg.FFmpeg) *resolver { - return &resolver{ds: ds, ffmpeg: ffm} -} - -func (r *resolver) resolve(ctx context.Context, item model.ArtworkQueueItem) (resolution, error) { - kind, _ := model.ParseKind(item.ItemKind) - switch kind { - case model.KindAlbumArtwork: - return r.resolveAlbum(ctx, item.ItemID) - case model.KindArtistArtwork: - return r.resolveArtist(ctx, item.ItemID) - case model.KindPlaylistArtwork: - return r.resolvePlaylist(ctx, item.ItemID) - case model.KindRadioArtwork: - return r.resolveRadio(ctx, item.ItemID) - case model.KindMediaFileArtwork: - return r.resolveMediaFile(ctx, item.ItemID) - default: - return resolution{}, fmt.Errorf("artwork: kind %q is not resolvable by the worker", item.ItemKind) - } -} - -// Explainable reports whether TracingResolver can walk this kind's sources and report which one -// won; playlists and radios resolve from a fixed internal order, with nothing configured to explain. -func Explainable(kind model.Kind) bool { - switch kind { - case model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, model.KindMediaFileArtwork: - return true - } - return false -} - -// MayFetchExternal reports whether resolving this kind can issue an external request under the -// current config. Playlists inherit the album chain: the generated grid resolves album art. -func MayFetchExternal(kind model.Kind) bool { - switch kind { - case model.KindArtistArtwork: - return chainFetchesExternal(conf.Server.ArtistArtPriority) - case model.KindAlbumArtwork: - return chainFetchesExternal(conf.Server.CoverArtPriority) - case model.KindPlaylistArtwork: - return conf.Server.EnableM3UExternalAlbumArt || chainFetchesExternal(conf.Server.CoverArtPriority) - default: - return false - } -} - -// ImageAgentCount is how many enabled agents provide artist and album images. -type ImageAgentCount struct{ Artist, Album int } - -// NewImageAgentCount counts what an external step would consult, so an estimate and the gate that -// guards it cannot disagree about which agents exist. -func NewImageAgentCount(ag *agents.Agents) ImageAgentCount { - if ag == nil { - return ImageAgentCount{} - } - return ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())} -} - -// ExternalLookupsPerItem reports what resolving one item of this kind can cost: every image agent is -// tried, and a zero count still bills one, so agents the caller cannot see never read as free. -func ExternalLookupsPerItem(kind model.Kind, agents ImageAgentCount) int64 { - if !MayFetchExternal(kind) { - return 0 - } - switch kind { - case model.KindArtistArtwork: - return int64(max(agents.Artist, 1)) - case model.KindAlbumArtwork: - return int64(max(agents.Album, 1)) - case model.KindPlaylistArtwork: - var n int64 - if conf.Server.EnableM3UExternalAlbumArt { - n++ - } - if chainFetchesExternal(conf.Server.CoverArtPriority) { - n += PlaylistGridSamples * int64(max(agents.Album, 1)) - } - return n - } - return 0 -} - -func chainFetchesExternal(priority string) bool { - for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { - if strings.TrimSpace(pattern) == externalCandidate { - return true - } - } - return false -} - -// Album and artist fetches stop here when the resolver is local-only, rather than at each point in -// the chain walk; resolvePlaylist gates the third network path, the m3u image URL, itself. -func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, error) { - if r.ext == nil { - return nil, "", nil - } - return fetchAlbumImage(ctx, r.ext.agents, r.ext.gate, al) -} - -func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, error) { - if r.ext == nil { - return nil, "", nil - } - return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar) -} - -// resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources. -func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) { - al, err := r.ds.Album().Get(ctx, albumID) - if err != nil { - return resolution{}, err - } - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, r.ds, *al) - if err != nil { - return resolution{}, err - } - lib, err := loadLibraryView(ctx, r.ds, al.LibraryID) - if err != nil { - return resolution{}, err - } - - chain := chainState{trace: traceFrom(ctx)} - for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") { - pattern = strings.TrimSpace(pattern) - if pattern == "" { - continue - } - switch { - case pattern == "embedded": - res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath) - if res, ok = chain.try(pattern, res, ok); ok { - return res, nil - } - case pattern == externalCandidate: - if rd, name, err := r.fetchExternalAlbum(ctx, *al); rd != nil { - return resolution{reader: rd, source: ExternalPrefix + name}, nil - } else if err != nil { - chain.extErr = longerRetry(chain.extErr, err) - } - case len(imgFiles) > 0: - res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern) - if res, ok = chain.try(pattern, res, ok); ok { - return res, nil - } - default: - chain.record(pattern, OutcomeSkipped, "no images in album folder") - } - } - return chain.exhausted(), nil -} - -// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority. -func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) { - ar, err := r.ds.Artist().Get(ctx, artistID) - if err != nil { - return resolution{}, err - } - chain := chainState{trace: traceFrom(ctx)} - upload, uploadOK := resolveLocalFile(ar.UploadedImagePath(), "upload") - if res, ok := chain.try("upload", upload, uploadOK); ok { - return res, nil - } - if upload.localError { - // The upload outranks every other source; falling through would persist a lower-priority - // image as if the upload were gone. - return upload, nil - } - - // Only consider albums where the artist is the sole album artist. - als, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)}) - if err != nil { - return resolution{}, err - } - albumPaths, imgFiles, _, err := loadArtistAlbumRoots(ctx, r.ds, als) - if err != nil { - return resolution{}, err - } - artistFolder, _, err := loadArtistFolder(ctx, r.ds, als, albumPaths) - if err != nil { - return resolution{}, err - } - var lib libraryView - if len(als) > 0 { - lib, err = loadLibraryView(ctx, r.ds, als[0].LibraryID) - if err != nil { - return resolution{}, err - } - } - - for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { - pattern = strings.TrimSpace(pattern) - if pattern == "" { - continue - } - switch { - case pattern == externalCandidate: - if rd, name, err := r.fetchExternalArtist(ctx, *ar); rd != nil { - return resolution{reader: rd, source: ExternalPrefix + name}, nil - } else if err != nil { - chain.extErr = longerRetry(chain.extErr, err) - } - case pattern == "image-folder": - res, ok := resolveArtistImageFolder(ar) - if res, ok = chain.try(pattern, res, ok); ok { - return res, nil - } - case strings.HasPrefix(pattern, "album/"): - if lib.FS == nil { - chain.record(pattern, OutcomeSkipped, "artist has no albums") - continue - } - res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/")) - if res, ok = chain.try(pattern, res, ok); ok { - return res, nil - } - default: - if lib.FS == nil { - chain.record(pattern, OutcomeSkipped, "artist has no albums") - continue - } - if artistFolder == "" { - chain.record(pattern, OutcomeSkipped, "no artist folder") - continue - } - res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern) - if res, ok = chain.try(pattern, res, ok); ok { - return res, nil - } - } - } - return chain.exhausted(), nil -} - -// PlaylistGridSamples is how many albums resolvePlaylist samples to build the generated grid. -const PlaylistGridSamples = 4 - -// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid. -func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) { - pl, err := r.ds.Playlist().Get(ctx, playlistID) - if err != nil { - return resolution{}, err - } - - var extErr error - for _, src := range []struct{ path, source string }{ - {pl.UploadedImagePath(), "upload"}, - {findPlaylistSidecarPath(ctx, pl.Path), "folder"}, - } { - res, ok := resolveLocalFile(src.path, src.source) - if ok { - return res, nil - } - if res.localError { - // These outrank the generated grid; falling through would replace them with it. - return res, nil - } - } - // A local ExternalImageURL is file-backed and served in place; only http(s) needs the gated fetch. - localImg, remoteImg := classifyPlaylistImage(pl.ExternalImageURL) - if localImg != "" { - res, ok := resolveLocalFile(localImg, "folder") - if ok { - return res, nil - } - if res.localError { - return res, nil - } - } - if r.ext == nil { - // The remote fetch and the generated grid are worker-only; a request must do neither. - return resolution{}, nil - } - if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt { - sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) } - if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok { - return res, nil - } else if err != nil { - extErr = longerRetry(extErr, err) - // Record it here with its detail: once album sampling adds its own steps, the processor's - // empty-trace fallback no longer fires, and the error that forced the retry would be lost. - traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()}) - } - } - - tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false) - if tracks == nil { - return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID) - } - albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) - if err != nil { - return resolution{}, err - } - - var tiles []image.Image - var tileErr error // first internal (non-external) tile failure, e.g. album deleted mid-flight - for _, albumID := range albumIDs { - res, err := r.resolveAlbum(ctx, albumID) - if err != nil { - if tileErr == nil { - tileErr = err - } - continue - } - if res.extErr != nil { - extErr = longerRetry(extErr, res.extErr) - } - if res.reader == nil { - continue - } - tile, decErr := decodeTile(res.reader) - res.reader.Close() - if decErr == nil { - tiles = append(tiles, tile) - } - if len(tiles) == PlaylistGridSamples { - break - } - } - if len(tiles) == 0 { - // A tile-level failure must never resolve as a clean absent. - if tileErr != nil { - return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr) - } - return resolution{extErr: extErr}, nil - } - // Grow to 4 tiles by repeating what we have. - switch len(tiles) { - case 2: - tiles = append(tiles, tiles[1], tiles[0]) - case 3: - tiles = append(tiles, tiles[0]) - } - grid, err := assembleTiles(tiles) - if err != nil { - return resolution{extErr: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error - } - return resolution{reader: grid, source: "generated", extErr: extErr}, nil -} - -// resolveRadio serves only an uploaded image; there is no fallback. -func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) { - radio, err := r.ds.Radio().Get(ctx, radioID) - if err != nil { - return resolution{}, err - } - res, _ := resolveLocalFile(radio.UploadedImagePath(), "upload") - return res, nil -} - -// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art -// is a definitive absent. -func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) { - mf, err := r.ds.MediaFile().Get(ctx, id) - if err != nil { - return resolution{}, err - } - chain := chainState{trace: traceFrom(ctx)} - switch { - case !conf.Server.EnableMediaFileCoverArt: - chain.record("embedded", OutcomeSkipped, "EnableMediaFileCoverArt is off") - return resolution{}, nil - case !mf.HasCoverArt: - chain.record("embedded", OutcomeMiss, "the track has no embedded cover art") - return resolution{}, nil - } - lib, err := loadLibraryView(ctx, r.ds, mf.LibraryID) - if err != nil { - return resolution{}, err - } - res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path) - if res, ok = chain.try("embedded", res, ok); ok { - return res, nil - } - return chain.exhausted(), nil -} - -// resolveDisc walks conf.Server.DiscArtPriority. Disc artwork keeps no state row and is never -// queued: the serving path reads it through on every request, so this only ever explains. -func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, error) { - dr, err := newDiscArtworkReader(ctx, r.ds, model.ArtworkID{Kind: model.KindDiscArtwork, ID: id}) - if err != nil { - return resolution{}, err - } - chain := chainState{trace: traceFrom(ctx)} - return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain) -} - -// resolveExternalStep runs a single external sourceFunc through the named gate. A not-found is a -// definitive "no", returned as (_, false, nil); any other error is a failure the caller records. -func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (resolution, bool, error) { - r, path, err := gate(name, sf) - if r != nil { - return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, nil - } - if errors.Is(err, model.ErrNotFound) { - return resolution{}, false, nil - } - return resolution{}, false, err -} - -// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a -// remote http(s) URL; at most one is set. -func classifyPlaylistImage(imageURL string) (localPath string, remote *url.URL) { - if imageURL == "" { - return "", nil - } - u, err := url.Parse(imageURL) - if err != nil { - return imageURL, nil // unparseable → treat as a local path - } - switch u.Scheme { - case "http", "https": - return "", u - case "file": - return u.Path, nil - default: - return imageURL, nil - } -} - -func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) { - if embedRel == "" { - return resolution{}, false - } - abs := lib.Abs(embedRel) - var unreadable bool - for _, sf := range []sourceFunc{fromTag(ctx, lib.FS, embedRel), fromFFmpegTag(ctx, ffm, abs)} { - r, _, err := sf() - if r != nil { - return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtimeViaFS(lib.FS, embedRel)}, true - } - unreadable = unreadable || errors.Is(err, errSourceUnreadable) - } - return resolution{localError: unreadable}, false -} - -// resolveFolderSource turns a source that yields a library-relative image path into a folder -// resolution, keeping an existing-but-unopenable file distinct from an absent one. -func resolveFolderSource(lib libraryView, sf sourceFunc) (resolution, bool) { - r, path, err := sf() - if r == nil { - return resolution{localError: errors.Is(err, errSourceUnreadable)}, false - } - return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true -} - -func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) { - return resolveFolderSource(lib, fromExternalFile(ctx, lib.FS, imgFiles, pattern)) -} - -// IsArtistImageFile reports whether a file name matches any file-glob token of ArtistArtPriority. -// Basename-only on purpose: the chain climbs parent folders, so a token's prefix is not fixed. -func IsArtistImageFile(name string) bool { - name = strings.ToLower(name) - for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { - pattern = strings.TrimSpace(pattern) - if pattern == "" || pattern == externalCandidate || pattern == "image-folder" { - continue - } - if ok, _ := path.Match(path.Base(pattern), name); ok { - return true - } - } - return false -} - -func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) { - folder := conf.Server.ArtistImageFolder - if folder == "" { - return resolution{}, false - } - return resolveLocalFile(findImageInArtistFolder(folder, ar.MbzArtistID, ar.Name), "folder") -} - -func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFolder, pattern string) (resolution, bool) { - r, path, err := fromArtistFolder(ctx, lib.FS, lib.absRoot, artistFolder, pattern)() - if r == nil { - return resolution{localError: errors.Is(err, errSourceUnreadable)}, false - } - return resolution{reader: r, source: "folder", sourcePath: path, refMtime: mtimeOf(path)}, true -} - -// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other -// open failure says nothing about whether the image exists. -func resolveLocalFile(path, source string) (resolution, bool) { - if path == "" || !model.IsImageFile(path) { - return resolution{}, false - } - f, err := os.Open(path) - if err != nil { - // Carry the source label even on a fault, so a resolver with no chain (playlist/radio) can - // still name what faulted in the trace. - return resolution{source: source, localError: !errors.Is(err, fs.ErrNotExist)}, false - } - return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true -} - -func mtimeOf(path string) int64 { - info, err := os.Stat(path) - if err != nil { - return 0 - } - return info.ModTime().UnixNano() -} - -// mtimeViaFS stats through the library FS, since library roots in tests may not be real OS paths. -func mtimeViaFS(fsys fs.FS, name string) int64 { - if fsys == nil || name == "" { - return 0 - } - info, err := fs.Stat(fsys, name) - if err != nil { - return 0 - } - return info.ModTime().UnixNano() -} diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go deleted file mode 100644 index 2a36531bb..000000000 --- a/core/artwork/resolve_test.go +++ /dev/null @@ -1,844 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "errors" - "image" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync/atomic" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "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" -) - -var _ = Describe("IsArtistImageFile", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("matches bare and album/-prefixed glob tokens, case-insensitively", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artistfolder.*, external" - Expect(IsArtistImageFile("Artist.jpg")).To(BeTrue()) - Expect(IsArtistImageFile("artistfolder.png")).To(BeTrue()) - Expect(IsArtistImageFile("cover.jpg")).To(BeFalse()) - }) - - It("matches a directory-bearing glob by its basename", func() { - conf.Server.ArtistArtPriority = "images/artist.*, external" - Expect(IsArtistImageFile("artist.jpg")).To(BeTrue()) - Expect(IsArtistImageFile("cover.jpg")).To(BeFalse()) - }) - - It("does not treat non-file tokens as globs", func() { - conf.Server.ArtistArtPriority = "image-folder, external" - Expect(IsArtistImageFile("image-folder")).To(BeFalse()) - Expect(IsArtistImageFile("external")).To(BeFalse()) - }) -}) - -var _ = Describe("resolveItem", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - folderRepo *fakeFolderRepo - libRepo *tests.MockLibraryRepo - ffm *tests.MockFFmpeg - ag *agents.Agents - repoRoot string - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - var err error - repoRoot, err = os.Getwd() - Expect(err).ToNot(HaveOccurred()) - - folderRepo = &fakeFolderRepo{} - libRepo = &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - ffm = tests.NewMockFFmpeg("") - ag = agents.GetAgents(&tests.MockDataStore{}, nil) - ds = &tests.MockDataStore{ - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - }) - - Describe("kind dispatch", func() { - It("returns an error for kinds the worker never enqueues", func() { - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "zz", ItemID: "x"}) - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("media file", func() { - BeforeEach(func() { - conf.Server.EnableMediaFileCoverArt = true - ds.MockedMediaFile = tests.CreateMockMediaFileRepo() - }) - - It("resolves embedded art from the track file", func() { - ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("embedded")) - Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) - Expect(res.refMtime).To(BeNumerically(">", 0)) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("resolves absent when the track has no cover art", func() { - ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mf2", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: false}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("resolves absent when media file cover art is disabled", func() { - conf.Server.EnableMediaFileCoverArt = false - ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mf3", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - }) - - It("returns the error when the track is not in the DB", func() { - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "missing"}) - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(res.reader).To(BeNil()) - }) - }) - - Describe("album", func() { - BeforeEach(func() { - conf.Server.CoverArtPriority = "cover.jpg, embedded" - ds.MockedAlbum = tests.CreateMockAlbumRepo() - }) - - It("resolves folder art from the library FS", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) - Expect(res.refMtime).To(BeNumerically(">", 0)) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("falls back to embedded art when no folder image matches", func() { - folderRepo.result = nil - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("embedded")) - Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) - Expect(res.refMtime).To(BeNumerically(">", 0)) - }) - - It("sets extErr when the external source errors without being not-found", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al3", Name: "Album"}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).To(HaveOccurred()) - }) - - It("does not set extErr when the external source reports not-found", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al4", Name: "Album"}, - }) - // no image agents enabled -> the external step is a definitive not-found - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("carries extErr onto a fallback folder hit after a transient external failure", func() { - conf.Server.CoverArtPriority = "external, cover.jpg" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al6", Name: "Album", FolderIDs: []string{"f1"}}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(res.extErr).To(HaveOccurred()) - }) - - It("does not carry extErr onto a fallback folder hit after a definitive external not-found", func() { - conf.Server.CoverArtPriority = "external, cover.jpg" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al7", Name: "Album", FolderIDs: []string{"f1"}}, - }) - // no image agents enabled -> the external step is a definitive not-found - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("routes the external step through the injected gate, keyed by agent name", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al5", Name: "Album"}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) - var gatedNames []string - gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - gatedNames = append(gatedNames, name) - return f() - } - - res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.extErr).To(HaveOccurred()) - Expect(gatedNames).To(Equal([]string{"failAgent"})) - }) - }) - - Describe("artist", func() { - It("resolves the uploaded image before any priority chain lookup", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "artist"), 0755)).To(Succeed()) - imgPath := filepath.Join(tmpDir, "artwork", "artist", "ar1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) - - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}}) - ds.MockedArtist = artistRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("upload")) - Expect(res.sourcePath).To(Equal(imgPath)) - }) - - It("falls through to the ArtistArtPriority chain when there is no upload", func() { - conf.Server.ArtistArtPriority = "album/artist.*" - folderRepo.result = []model.Folder{{ - LibraryPath: testFileLibPath(repoRoot), - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, - }} - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}}) - ds.MockedArtist = artistRepo - ds.MockedAlbum = tests.CreateMockAlbumRepo() - ds.MockedAlbum.(*tests.MockAlbumRepo).All = model.Albums{ - {ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}, - } - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) - }) - - It("sets extErr when the external source errors without being not-found", func() { - conf.Server.ArtistArtPriority = "external" - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}}) - ds.MockedArtist = artistRepo - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).To(HaveOccurred()) - }) - - It("does not set extErr when the external source reports not-found", func() { - conf.Server.ArtistArtPriority = "external" - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) - ds.MockedArtist = artistRepo - // no image agents enabled -> the external step is a definitive not-found - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("routes the external step through the injected gate, keyed by agent name", func() { - conf.Server.ArtistArtPriority = "external" - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) - ds.MockedArtist = artistRepo - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) - var gatedNames []string - gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - gatedNames = append(gatedNames, name) - return f() - } - - res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.extErr).To(HaveOccurred()) - Expect(gatedNames).To(Equal([]string{"failAgent"})) - }) - }) - - Describe("radio", func() { - It("yields an empty resolution when there is no uploaded image", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio"}} - ds.MockedRadio = radioRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(Equal(resolution{})) - }) - - It("resolves the uploaded image when set", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) - imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra2_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) - - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{"ra2": {ID: "ra2", Name: "Radio", UploadedImage: "ra2_test.jpg"}} - ds.MockedRadio = radioRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("upload")) - Expect(res.sourcePath).To(Equal(imgPath)) - }) - }) - - Describe("playlist", func() { - BeforeEach(func() { - conf.Server.CoverArtPriority = "cover.jpg" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum = tests.CreateMockAlbumRepo() - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "t1", Name: "T1", FolderIDs: []string{"f1"}}, - {ID: "t2", Name: "T2", FolderIDs: []string{"f1"}}, - {ID: "t3", Name: "T3", FolderIDs: []string{"f1"}}, - {ID: "t4", Name: "T4", FolderIDs: []string{"f1"}}, - }) - }) - - DescribeTable("yields a generated grid from up to 4 album tiles", - func(albumIDs []string, expectedSize int) { - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: albumIDs} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("generated")) - - img, format, err := image.Decode(res.reader) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) - Expect(img.Bounds().Dx()).To(Equal(expectedSize)) - Expect(img.Bounds().Dy()).To(Equal(expectedSize)) - }, - // tileSize-1: the 4-tile canvas is built as [0, tileSize-1]. - Entry("1 album -> single tile", []string{"t1"}, tileSize/2), - Entry("2 albums -> duplicated to 4 tiles", []string{"t1", "t2"}, tileSize-1), - Entry("3 albums -> duplicated to 4 tiles", []string{"t1", "t2", "t3"}, tileSize-1), - Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1), - ) - - // The grid samples album art through the full album chain, so a playlist reaches the - // network even with the m3u fetch off. - It("calls the album image agents for its grid tiles when m3u art is disabled", func() { - conf.Server.EnableM3UExternalAlbumArt = false - conf.Server.CoverArtPriority = "external" - folderRepo.result = nil - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plgrid", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} - ds.MockedPlaylist = plRepo - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) - var gatedNames []string - gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - gatedNames = append(gatedNames, name) - return f() - } - - _, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plgrid"}) - Expect(err).ToNot(HaveOccurred()) - Expect(gatedNames).To(Equal([]string{"failAgent", "failAgent"}), "one lookup per sampled album") - }) - - It("resolves the uploaded image before the generated grid", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "playlist"), 0755)).To(Succeed()) - imgPath := filepath.Join(tmpDir, "artwork", "playlist", "plu_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded playlist image"), 0600)).To(Succeed()) - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plu", Name: "Playlist", UploadedImage: "plu_test.jpg"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("upload")) - Expect(res.sourcePath).To(Equal(imgPath)) - }) - - It("resolves a sidecar image next to the playlist file before the grid", func() { - plDir := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(plDir, "list.m3u"), []byte("#EXTM3U"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(plDir, "list.jpg"), []byte("sidecar image"), 0600)).To(Succeed()) - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pls", Name: "Playlist", Path: filepath.Join(plDir, "list.m3u")}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("list.jpg")) - }) - - It("serves a local ExternalImageURL as a file-backed reference (staleness-checked)", func() { - dir := GinkgoT().TempDir() - imgPath := filepath.Join(dir, "cover.png") - Expect(os.WriteFile(imgPath, []byte("local external image"), 0600)).To(Succeed()) - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pll", Name: "Playlist", ExternalImageURL: imgPath}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pll"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(res.sourcePath).To(Equal(imgPath)) - Expect(res.refMtime).To(BeNumerically(">", 0)) - }) - - It("never opens a local ExternalImageURL that is not an image file", func() { - folderRepo.result = nil // no grid tiles, so only the local file could produce a reader - dir := GinkgoT().TempDir() - secretPath := filepath.Join(dir, "config.ini") - Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed()) - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plni", Name: "Playlist", ExternalImageURL: secretPath}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plni"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.sourcePath).ToNot(Equal(secretPath)) - }) - - It("routes ExternalImageURL through extGate and sets extError on transient failure", func() { - conf.Server.EnableM3UExternalAlbumArt = true - folderRepo.result = nil // no grid tiles, so the external failure is what surfaces - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "ple", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - var gatedNames []string - gate := func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - gatedNames = append(gatedNames, name) - return nil, "", errors.New("network down") - } - - res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).To(HaveOccurred()) - Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"") - }) - - It("records the m3u failure in the trace even when album sampling adds its own steps", func() { - conf.Server.EnableM3UExternalAlbumArt = true - folderRepo.result = nil // the sampled album yields no tile, so the m3u failure is what forced the retry - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plm3u", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - gate := func(string, func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - return nil, "", errors.New("network down") - } - - trace := &ChainTrace{} - res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace), - model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.extErr).To(HaveOccurred()) - - steps := trace.Steps() - var m3u *TraceStep - for i := range steps { - if steps[i].Candidate == ExternalPrefix+"m3u" && steps[i].Outcome == OutcomeError { - m3u = &steps[i] - } - } - Expect(m3u).ToNot(BeNil(), "the m3u fetch error must be traced at its source, not left to the empty-trace fallback") - Expect(m3u.Detail).To(Equal("network down"), - "the trace must carry the underlying error so explain can tell a timeout from an HTTP error") - }) - - It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() { - folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plm", Name: "Playlist", ExternalImageURL: "/nonexistent/path/cover.jpg"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() { - conf.Server.EnableM3UExternalAlbumArt = true - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl404", Name: "Playlist", ExternalImageURL: srv.URL}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("generated")) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - // A local resolver holds no agents: reaching the external branch would panic, not degrade. - It("skips the external step instead of dereferencing absent agents", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum = tests.CreateMockAlbumRepo() - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) - - res, err := newLocalResolver(ds, ffm).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).ToNot(HaveOccurred(), "a skipped step is not a failed one") - }) - - // The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously. - It("resolves a playlist locally without fetching remotely or building the grid", func() { - conf.Server.EnableM3UExternalAlbumArt = true - var hits atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - hits.Add(1) - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pllocal", Name: "Playlist", ExternalImageURL: srv.URL}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pllocal"} - - res, err := newLocalResolver(ds, ffm).resolve(ctx, item) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil(), "no local source, and the grid is worker-only") - Expect(hits.Load()).To(BeZero(), "a request must never reach the network") - - worker, err := newResolver(ds, ag, ffm, nil).resolve(ctx, item) - Expect(err).ToNot(HaveOccurred()) - Expect(worker.reader).ToNot(BeNil()) - defer worker.reader.Close() - Expect(worker.source).To(Equal("generated"), "the worker does build the grid") - Expect(hits.Load()).To(Equal(int32(1)), "and the worker does fetch") - }) - - It("treats an ExternalImageURL 500 as a transient failure and sets extError", func() { - conf.Server.EnableM3UExternalAlbumArt = true - folderRepo.result = nil // no grid tiles, so the external failure is what surfaces - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer srv.Close() - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl500", Name: "Playlist", ExternalImageURL: srv.URL}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).To(HaveOccurred()) - }) - - It("yields an empty resolution when no album has art", func() { - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "empty1", Name: "Empty"}, - }) - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl2", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"empty1"}} - ds.MockedPlaylist = plRepo - folderRepo.result = nil - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.source).To(BeEmpty()) - }) - - It("skips a grid tile whose declared dimensions are a decompression bomb", func() { - libRoot := GinkgoT().TempDir() - Expect(os.MkdirAll(filepath.Join(libRoot, "bomb"), 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libRoot, "bomb", "cover.jpg"), pngHeaderWithDims(50000, 50000), 0600)).To(Succeed()) - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - folderRepo.result = []model.Folder{{Path: "bomb", ImageFiles: []string{"cover.jpg"}}} - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plbomb", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plbomb"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.source).To(BeEmpty()) - }) - - It("does not resolve as absent when every sampled album fails to resolve", func() { - // The album ids are absent from MockAlbumRepo, so every tile fails non-externally. - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl3", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"}) - Expect(err).To(HaveOccurred()) - Expect(res).To(Equal(resolution{})) - }) - - It("returns an error when the playlist tracks cannot be loaded", func() { - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}}) - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"}) - Expect(err).To(HaveOccurred()) - Expect(res).To(Equal(resolution{})) - }) - }) -}) - -// decodeTile runs before the processor's own guards, so it must enforce the caps itself. -var _ = Describe("decodeTile", func() { - It("rejects a decompression bomb before the full decode", func() { - data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap - _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("dimensions")) - }) - - It("rejects a tile larger than the size cap", func() { - data := bytes.Repeat([]byte{0}, int(maxImageBytes())+1) - _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("Explainable", func() { - It("is true for the kinds the resolver walks", func() { - Expect(Explainable(model.KindArtistArtwork)).To(BeTrue()) - Expect(Explainable(model.KindAlbumArtwork)).To(BeTrue()) - Expect(Explainable(model.KindDiscArtwork)).To(BeTrue()) - Expect(Explainable(model.KindMediaFileArtwork)).To(BeTrue()) - }) - - It("is false for the kinds resolved from a fixed internal order", func() { - Expect(Explainable(model.KindPlaylistArtwork)).To(BeFalse()) - Expect(Explainable(model.KindRadioArtwork)).To(BeFalse()) - }) -}) - -var _ = Describe("MayFetchExternal", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.CoverArtPriority = "cover.*, embedded" - conf.Server.ArtistArtPriority = "artist.*" - conf.Server.EnableM3UExternalAlbumArt = false - }) - - It("is true for the kinds whose chain includes the external candidate", func() { - conf.Server.CoverArtPriority = "cover.*, external" - conf.Server.ArtistArtPriority = "artist.*, external" - Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeTrue()) - Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeTrue()) - }) - - It("is false for a chain with no external candidate", func() { - Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeFalse()) - Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeFalse()) - }) - - It("is true for playlists when the m3u image fetch is enabled", func() { - conf.Server.EnableM3UExternalAlbumArt = true - Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue()) - }) - - It("is true for playlists whose grid tiles resolve through an external album chain", func() { - conf.Server.CoverArtPriority = "cover.*, external" - Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue()) - }) - - It("is false for playlists with both paths off", func() { - Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeFalse()) - }) - - It("is false for the kinds that only read local files", func() { - conf.Server.CoverArtPriority = "external" - conf.Server.ArtistArtPriority = "external" - conf.Server.EnableM3UExternalAlbumArt = true - Expect(MayFetchExternal(model.KindRadioArtwork)).To(BeFalse()) - Expect(MayFetchExternal(model.KindMediaFileArtwork)).To(BeFalse()) - }) -}) - -var _ = Describe("ExternalLookupsPerItem", func() { - count := ImageAgentCount{Artist: 3, Album: 2} - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.CoverArtPriority = "cover.*, external" - conf.Server.ArtistArtPriority = "artist.*, external" - conf.Server.EnableM3UExternalAlbumArt = false - }) - - It("bills one call per agent, since the walk only stops early on a hit", func() { - Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(Equal(int64(3))) - Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(Equal(int64(2))) - }) - - It("bills a playlist for every album its grid samples", func() { - Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)). - To(Equal(int64(PlaylistGridSamples) * 2)) - }) - - It("adds the m3u image fetch on top of the grid", func() { - conf.Server.EnableM3UExternalAlbumArt = true - Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)). - To(Equal(int64(PlaylistGridSamples)*2 + 1)) - }) - - It("bills only the m3u fetch when the album chain stays local", func() { - conf.Server.CoverArtPriority = "cover.*" - conf.Server.EnableM3UExternalAlbumArt = true - Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(Equal(int64(1))) - }) - - It("still bills a call when no agent is visible, which plugins never are offline", func() { - none := ImageAgentCount{} - Expect(ExternalLookupsPerItem(model.KindArtistArtwork, none)).To(Equal(int64(1))) - Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, none)).To(Equal(int64(1))) - Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, none)). - To(Equal(int64(PlaylistGridSamples))) - }) - - It("is zero whenever the kind reaches no agent at all", func() { - conf.Server.CoverArtPriority = "cover.*" - conf.Server.ArtistArtPriority = "artist.*" - Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(BeZero()) - Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(BeZero()) - Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(BeZero()) - Expect(ExternalLookupsPerItem(model.KindRadioArtwork, count)).To(BeZero()) - }) -}) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index daa084c16..04a9257fb 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -3,55 +3,75 @@ package artwork import ( "bytes" "context" - "errors" "fmt" "io" "io/fs" "net/http" "net/url" "path/filepath" + "reflect" "regexp" + "runtime" "strings" "time" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/httpclient" - "github.com/navidrome/navidrome/utils/netguard" + "github.com/navidrome/navidrome/resources" "go.senan.xyz/taglib" ) -// errSourceUnreadable marks a candidate the resolver knows exists but could not read. Failing -// to open it is not evidence the entity has no artwork, so callers must not settle on absent. -var errSourceUnreadable = errors.New("artwork source unreadable") +func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) { + for _, f := range extractFuncs { + if ctx.Err() != nil { + return nil, "", ctx.Err() + } + start := time.Now() + r, path, err := f() + if r != nil { + msg := fmt.Sprintf("Found %s artwork", artID.Kind) + log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start)) + return r, path, nil + } + log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err) + } + return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable) +} type sourceFunc func() (r io.ReadCloser, path string, err error) +func (f sourceFunc) String() string { + name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() + name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core/artwork.") + if _, after, found := strings.Cut(name, ")."); found { + name = after + } + name = strings.TrimSuffix(name, ".func1") + return name +} + func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { - var openErr error for _, file := range files { _, name := filepath.Split(file) match, err := filepath.Match(pattern, strings.ToLower(name)) if err != nil { - log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file) + log.Warn(ctx, "Error matching cover art file to pattern", "pattern", pattern, "file", file) continue } - if !match || !model.IsImageFile(name) { + if !match { continue } f, err := libFS.Open(file) if err != nil { - log.Warn(ctx, "Artwork: Could not open cover art file", "file", file, err) - openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, file, err) + log.Warn(ctx, "Could not open cover art file", "file", file, err) continue } return f, file, nil } - if openErr != nil { - return nil, "", openErr - } return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files) } } @@ -70,7 +90,7 @@ func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc { } f, err := libFS.Open(relPath) if err != nil { - return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err) + return nil, "", err } rs, ok := f.(io.ReadSeeker) if !ok { @@ -83,7 +103,7 @@ func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc { ) if err != nil { f.Close() - return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err) + return nil, "", err } // Close in LIFO order: tf first (it holds rs internally), then f. defer f.Close() @@ -107,12 +127,12 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str for _, regex := range picTypeRegexes { for i, img := range images { if regex.MatchString(img.Type) { - log.Trace(ctx, "Artwork: Found embedded image", "type", img.Type, "path", path) + log.Trace(ctx, "Found embedded image", "type", img.Type, "path", path) return i } } } - log.Trace(ctx, "Artwork: Could not find a front image. Getting the first one", "type", images[0].Type, "path", path) + log.Trace(ctx, "Could not find a front image. Getting the first one", "type", images[0].Type, "path", path) return 0 } @@ -151,27 +171,52 @@ type readCloser struct { io.Closer } -// remoteImageClient fetches URLs from playlists and agents (plugins included), so it must not reach -// internal hosts. Shared so fetches reuse connections. -var remoteImageClient = httpclient.NewExternal(5 * time.Second) +func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc { + return func() (io.ReadCloser, string, error) { + r, _, err := a.Get(ctx, id, 0, false) + if err != nil { + return nil, "", err + } + return r, id.String(), nil + } +} + +func fromAlbumPlaceholder() sourceFunc { + return func() (io.ReadCloser, string, error) { + r, _ := resources.FS().Open(consts.PlaceholderAlbumArt) + return r, consts.PlaceholderAlbumArt, nil + } +} +func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc { + return func() (io.ReadCloser, string, error) { + imageUrl, err := provider.ArtistImage(ctx, ar.ID) + if err != nil { + return nil, "", err + } + + return fromURL(ctx, imageUrl) + } +} + +func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc { + return func() (io.ReadCloser, string, error) { + imageUrl, err := provider.AlbumImage(ctx, al.ID) + if err != nil { + return nil, "", err + } + + return fromURL(ctx, imageUrl) + } +} func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) { + hc := http.Client{Timeout: 5 * time.Second} req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil) - resp, err := remoteImageClient.Do(req) - if errors.Is(err, netguard.ErrPrivateAddress) { - // Retrying cannot change where the URL points: settle absent instead of tripping the breaker. - log.Warn(ctx, "Artwork: Refused to fetch image from a private or loopback address", "url", imageUrl, err) - return nil, "", model.ErrNotFound - } + req.Header.Set("User-Agent", consts.HTTPUserAgent) + resp, err := hc.Do(req) //nolint:gosec if err != nil { return nil, "", err } - // An agent-advertised URL that 404s is a definitive miss, not a fault: settle absent - // instead of retrying forever and tripping the artwork breaker. - if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone { - resp.Body.Close() - return nil, "", model.ErrNotFound - } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageUrl, resp.Status) diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go index bc81b56e7..4282575a5 100644 --- a/core/artwork/sources_internal_test.go +++ b/core/artwork/sources_internal_test.go @@ -5,87 +5,13 @@ import ( "errors" "io" "io/fs" - "net/http" - "net/http/httptest" - "net/netip" - "net/url" "os" - "strings" - "sync/atomic" "testing/fstest" - "time" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/httpclient" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("fromURL", func() { - var ( - hits atomic.Int32 - target *httptest.Server - ) - - BeforeEach(func() { - hits.Store(0) - target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - hits.Add(1) - _, _ = w.Write([]byte("image-bytes")) - })) - DeferCleanup(target.Close) - }) - - useClient := func(c *http.Client) { - prev := remoteImageClient - remoteImageClient = c - DeferCleanup(func() { remoteImageClient = prev }) - } - fetch := func(rawURL string) ([]byte, error) { - u, err := url.Parse(rawURL) - Expect(err).ToNot(HaveOccurred()) - r, _, err := fromURL(GinkgoT().Context(), u) - if err != nil { - return nil, err - } - defer r.Close() - return io.ReadAll(r) - } - // Stand-in for a public host: only 127.0.0.1 is allowed, so every other private address stays refused. - onlyLocalhostV4 := func() *http.Client { - return httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.1/32")) - } - - DescribeTable("refuses private and loopback targets as a definitive miss", - func(rawURL string) { - useClient(productionImageClient) - u, _ := url.Parse(target.URL) - _, err := fetch(strings.ReplaceAll(rawURL, "PORT", u.Port())) - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(hits.Load()).To(BeZero()) - }, - Entry("IPv4 loopback", "http://127.0.0.1:PORT/x"), - Entry("localhost", "http://localhost:PORT/x"), - Entry("cloud metadata", "http://169.254.169.254/"), - Entry("IPv6 loopback", "http://[::1]/"), - ) - - It("refuses a redirect from an allowed host to a loopback address", func() { - useClient(onlyLocalhostV4()) - redirector := httptest.NewServer(http.RedirectHandler(strings.Replace(target.URL, "127.0.0.1", "127.0.0.2", 1), http.StatusFound)) - DeferCleanup(redirector.Close) - - _, err := fetch(redirector.URL) - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(hits.Load()).To(BeZero()) - }) - - It("fetches from an allowed address", func() { - useClient(onlyLocalhostV4()) - Expect(fetch(target.URL + "/cover.jpg")).To(Equal([]byte("image-bytes"))) - }) -}) - var _ = Describe("fromExternalFile", func() { It("opens a matching file via the library FS", func() { fsys := fstest.MapFS{ @@ -122,18 +48,6 @@ var _ = Describe("fromExternalFile", func() { Expect(b).To(Equal([]byte("a"))) Expect(path).To(Equal("a/cover.jpg")) }) - - It("skips a matching file that is not an image", func() { - fsys := fstest.MapFS{ - "a/cover.ini": &fstest.MapFile{Data: []byte("password=secret")}, - "a/cover.jpg": &fstest.MapFile{Data: []byte("a")}, - } - f := fromExternalFile(GinkgoT().Context(), fsys, []string{"a/cover.ini", "a/cover.jpg"}, "cover.*") - r, path, err := f() - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - Expect(path).To(Equal("a/cover.jpg")) - }) }) var _ = Describe("fromTag", func() { diff --git a/core/artwork/thumbhash/testdata/alpha.png b/core/artwork/thumbhash/testdata/alpha.png deleted file mode 100644 index 6ac85943a..000000000 Binary files a/core/artwork/thumbhash/testdata/alpha.png and /dev/null differ diff --git a/core/artwork/thumbhash/testdata/gen_fixtures.mjs b/core/artwork/thumbhash/testdata/gen_fixtures.mjs deleted file mode 100644 index b2e488b51..000000000 --- a/core/artwork/thumbhash/testdata/gen_fixtures.mjs +++ /dev/null @@ -1,90 +0,0 @@ -// Generates lossless PNG fixtures <=100px. Run: node gen_fixtures.mjs -import { writeFileSync } from 'fs' -import { deflateSync } from 'zlib' - -const crcTable = Array.from({ length: 256 }, (_, n) => { - let c = n - for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 - return c >>> 0 -}) -const crc32 = (buf) => { - let c = 0xffffffff - for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8) - return (c ^ 0xffffffff) >>> 0 -} -const chunk = (type, data) => { - const len = Buffer.alloc(4) - len.writeUInt32BE(data.length) - const body = Buffer.concat([Buffer.from(type, 'ascii'), data]) - const crc = Buffer.alloc(4) - crc.writeUInt32BE(crc32(body)) - return Buffer.concat([len, body, crc]) -} -const png = (w, h, rgba) => { - const ihdr = Buffer.alloc(13) - ihdr.writeUInt32BE(w, 0) - ihdr.writeUInt32BE(h, 4) - ihdr[8] = 8 // bit depth - ihdr[9] = 6 // truecolor + alpha - const raw = Buffer.alloc(h * (w * 4 + 1)) - for (let y = 0; y < h; y++) { - raw[y * (w * 4 + 1)] = 0 // filter: none - for (let x = 0; x < w * 4; x++) raw[y * (w * 4 + 1) + 1 + x] = rgba[y * w * 4 + x] - } - return Buffer.concat([ - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), - chunk('IHDR', ihdr), - chunk('IDAT', deflateSync(raw)), - chunk('IEND', Buffer.alloc(0)), - ]) -} - -// A ramp has exactly-zero DCT coefficients, and a zero coefficient's nibble is decided by float -// rounding noise, so no two summation orders agree on it. Dither gives every coefficient signal. -let seed = 0 -const dither = () => { - seed ^= seed << 13 - seed ^= seed >>> 17 - seed ^= seed << 5 - seed >>>= 0 - return (seed % 9) - 4 -} -const shade = (v) => Math.max(0, Math.min(255, v + dither())) - -const make = (w, h, fn) => { - seed = 0x9e3779b9 - const rgba = new Uint8Array(w * h * 4) - for (let y = 0; y < h; y++) - for (let x = 0; x < w; x++) fn(rgba, (y * w + x) * 4, x, y, w, h) - return png(w, h, rgba) -} - -const gradient = (rgba, i, x, y, w, h) => { - rgba[i] = shade(Math.floor((255 * x) / w)) - rgba[i + 1] = shade(Math.floor((255 * y) / h)) - rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h))) - rgba[i + 3] = 255 -} -const solid = (rgba, i) => { - rgba[i] = 60 - rgba[i + 1] = 120 - rgba[i + 2] = 180 - rgba[i + 3] = 255 -} -// RGB must vary with position too: a constant color composited over its own average cancels to a -// flat L/P/Q (the bug this fixture exists to catch), so pair a color gradient with the alpha ramp. -const alphaRamp = (rgba, i, x, y, w, h) => { - rgba[i] = shade(Math.floor((255 * x) / w)) - rgba[i + 1] = shade(Math.floor((255 * y) / h)) - rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h))) - rgba[i + 3] = shade(Math.floor((255 * (x + 2 * y)) / (w + 2 * h))) -} - -const out = new URL('.', import.meta.url).pathname -writeFileSync(out + 'square.png', make(100, 100, gradient)) -writeFileSync(out + 'landscape.png', make(100, 60, gradient)) -writeFileSync(out + 'portrait.png', make(60, 100, gradient)) -writeFileSync(out + 'alpha.png', make(80, 80, alphaRamp)) -writeFileSync(out + 'solid.png', make(64, 64, solid)) -writeFileSync(out + 'tiny.png', make(1, 1, solid)) -console.log('fixtures written') diff --git a/core/artwork/thumbhash/testdata/gen_generated.mjs b/core/artwork/thumbhash/testdata/gen_generated.mjs deleted file mode 100644 index e8eae585e..000000000 --- a/core/artwork/thumbhash/testdata/gen_generated.mjs +++ /dev/null @@ -1,28 +0,0 @@ -// Produces generated.json: ThumbHashes of synthetic images, from the vendored reference. -// The pixels are a pure function of their index, so Go rebuilds them byte-for-byte from the same -// formula and only the hashes need committing. Run: node gen_generated.mjs -import { writeFileSync } from 'fs' -import { rgbaToThumbHash } from './thumbhash.js' - -const COUNT = 300 - -// mix is a stateless 32-bit finaliser; Go's mix in thumbhash_test.go must match it exactly. -const mix = (n) => { - n = Math.imul(n ^ (n >>> 16), 2246822507) >>> 0 - n = Math.imul(n ^ (n >>> 13), 3266489909) >>> 0 - return (n ^ (n >>> 16)) >>> 0 -} - -const out = [] -for (let i = 0; i < COUNT; i++) { - const w = 1 + (mix(i * 3 + 1) % 100) - const h = 1 + (mix(i * 3 + 2) % 100) - const rgba = new Uint8Array(w * h * 4) - for (let k = 0; k < rgba.length; k++) rgba[k] = mix(i * 1000003 + k) & 255 - // Random alpha is opaque essentially never, so half are forced opaque to exercise the 7x7 - // no-alpha layout as often as the 5x5-plus-alpha one. - if (i % 2 === 0) for (let k = 3; k < rgba.length; k += 4) rgba[k] = 255 - out.push({ w, h, hash: Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64') }) -} -writeFileSync(new URL('.', import.meta.url).pathname + 'generated.json', JSON.stringify(out) + '\n') -console.log(`${out.length} vectors; first ${out[0].w}x${out[0].h} ${out[0].hash}`) diff --git a/core/artwork/thumbhash/testdata/gen_golden.mjs b/core/artwork/thumbhash/testdata/gen_golden.mjs deleted file mode 100644 index 2825e0709..000000000 --- a/core/artwork/thumbhash/testdata/gen_golden.mjs +++ /dev/null @@ -1,37 +0,0 @@ -// Produces golden.json from the vendored reference. Run: node gen_golden.mjs -import { readFileSync, writeFileSync, readdirSync } from 'fs' -import { inflateSync } from 'zlib' -import { rgbaToThumbHash } from './thumbhash.js' - -// Minimal reader for the exact PNGs gen_fixtures.mjs writes: 8-bit RGBA, filter 0, single IDAT. -const readPNG = (buf) => { - let w = 0, h = 0 - const idat = [] - for (let off = 8; off < buf.length; ) { - const len = buf.readUInt32BE(off) - const type = buf.toString('ascii', off + 4, off + 8) - const data = buf.subarray(off + 8, off + 8 + len) - if (type === 'IHDR') { - w = data.readUInt32BE(0) - h = data.readUInt32BE(4) - if (data[8] !== 8 || data[9] !== 6) throw new Error('expected 8-bit RGBA') - } else if (type === 'IDAT') idat.push(data) - off += 12 + len - } - const raw = inflateSync(Buffer.concat(idat)) - const rgba = new Uint8Array(w * h * 4) - for (let y = 0; y < h; y++) { - if (raw[y * (w * 4 + 1)] !== 0) throw new Error('expected filter 0') - for (let x = 0; x < w * 4; x++) rgba[y * w * 4 + x] = raw[y * (w * 4 + 1) + 1 + x] - } - return { w, h, rgba } -} - -const dir = new URL('.', import.meta.url).pathname -const golden = {} -for (const name of readdirSync(dir).filter((f) => f.endsWith('.png')).sort()) { - const { w, h, rgba } = readPNG(readFileSync(dir + name)) - golden[name] = Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64') -} -writeFileSync(dir + 'golden.json', JSON.stringify(golden, null, 2) + '\n') -console.log(golden) diff --git a/core/artwork/thumbhash/testdata/generated.json b/core/artwork/thumbhash/testdata/generated.json deleted file mode 100644 index 1b49bbb03..000000000 --- a/core/artwork/thumbhash/testdata/generated.json +++ /dev/null @@ -1 +0,0 @@ -[{"w":28,"h":79,"hash":"4AcCCgBFgGVnc6OLh2u/b7Y="},{"w":94,"h":86,"hash":"4PeBBYAIBZlXeaVKmwl6Zx+i9EeEUqdlGA=="},{"w":61,"h":88,"hash":"3/cBBQCxf6l3ioV1WadpmG+B7Pm4"},{"w":29,"h":41,"hash":"H/iBDAIHX5Chbnyqawdo8otij7N3a0qJBA=="},{"w":63,"h":74,"hash":"3wcCBgB0x884dZeDiopMhclYDUpUAm4="},{"w":29,"h":25,"hash":"HwiCBIIHF79zi6+Gg2fAhk/4fEtnapaYDQ=="},{"w":57,"h":98,"hash":"H/gBBABIejmXynwHHoVAnvdXSw=="},{"w":71,"h":69,"hash":"IPiBBYAHmD2KUW/lQQyLaLCDh0KklFn32g=="},{"w":71,"h":50,"hash":"3/cBBYBohqhLaJpwZoZUd011gBCJ"},{"w":89,"h":83,"hash":"4PeBBYAHkBp+Q/bXx0CSiZ+sT4Z4fIyYZg=="},{"w":92,"h":57,"hash":"IPgBBICgJdaPlJqBnUfQcGbwdA=="},{"w":39,"h":95,"hash":"3/eBAgAIymeYlAuWDQ9RUidjZat7jxk="},{"w":100,"h":82,"hash":"4AcCBoDiuKh2N+TrR6fh9nFm+5lp8Kc="},{"w":92,"h":93,"hash":"HwiCBQAIuI2JaEYDVRA6gU/q5PxIULPapw=="},{"w":67,"h":90,"hash":"IPgBBQC1bGaGk47Hl3+nddp6vwuY"},{"w":78,"h":71,"hash":"HwiCBYAHmZZIYn9JhXCLjZaQSWV2ZUGrfw=="},{"w":85,"h":42,"hash":"4AcCA4KeKZCAlcZzkAz7i6k="},{"w":8,"h":50,"hash":"3/eBCQAH+IVwSkj7IiZPL/h0dYpCSEg="},{"w":13,"h":78,"hash":"HwgCCQJGjqtkmUowdno/3fY="},{"w":66,"h":95,"hash":"HwiCAwAHCjuAqh2Fn/H9K5loYGiYmJU="},{"w":2,"h":84,"hash":"nvcFEQJ7c7iHmKwJj5iHCGA="},{"w":37,"h":32,"hash":"IAiCBIIIldBHeXKZ6f3EA2DV0K2GZkvDBA=="},{"w":41,"h":25,"hash":"3wcCDII7Jst5c0WFhga8cEnTBg=="},{"w":57,"h":26,"hash":"IPiBAoAIuvRneZfgjPSmAhZrl9W8SPU="},{"w":31,"h":40,"hash":"IPgBDQK6o7REKkCxJ1cXRlNqsK+B"},{"w":7,"h":69,"hash":"3veBCQIHdoKrFgYOxqU5cAVYqapmgjQ="},{"w":82,"h":59,"hash":"3/cBBYD4M6tIe6iJenZX0jv3i6QF"},{"w":85,"h":48,"hash":"IAiCA4AHyKR2lgvPG0TFBnXQWGkydzw="},{"w":53,"h":67,"hash":"HwgCBgKbVol5V393ersDOea29bSlZPk="},{"w":79,"h":26,"hash":"IAiCAoAI6QZtSGnvJmnwcJiHZquG+Hc="},{"w":14,"h":28,"hash":"3/cBDAJIhLeYi1+pu1owyIVvMg=="},{"w":85,"h":78,"hash":"4AeCBYAHXPhldaZ2ibVPVQmKh/iJlZQrig=="},{"w":26,"h":67,"hash":"H/gBCwI4T6OnR3GPhq81kGo="},{"w":51,"h":13,"hash":"IPiBCYII24eGC4VNfYAJR/S6V8WLeyg="},{"w":57,"h":81,"hash":"4AcCBQCoojSIY0qYe3f3UQGuawz+"},{"w":8,"h":72,"hash":"4PeBCQIHSUVYVn8ANrQoB3UWWYiXWbA="},{"w":89,"h":40,"hash":"IAgCA4LDe0AG5hb2aM8bcEM="},{"w":55,"h":94,"hash":"3/eBAwAHpIVmT7VvjwahW2NPhkiaDok="},{"w":59,"h":81,"hash":"H/gBDQBERoZIo4nKhqgKaJYvSkVA"},{"w":41,"h":45,"hash":"3/eBBQAHdeyYm3UGRR99adcGQ0RwRZpGmA=="},{"w":11,"h":28,"hash":"HxgCCwLHZnhGlGVggoAJ6GU="},{"w":8,"h":36,"hash":"4PeBCQIIbWHpldBF78rO/EKZaaiAqHo="},{"w":40,"h":100,"hash":"IAgCAwAIT2uoiESGs1BDrxw="},{"w":63,"h":33,"hash":"3weCA4IHmrEpmk9KVK92+LOxI+sLqfs="},{"w":72,"h":63,"hash":"HwgCBoCYlQZkaHeXaeabRJit12VwH7Y="},{"w":29,"h":90,"hash":"3/eBAgAIzqlo+KRtoAl1qh2KO7ZwWWo="},{"w":81,"h":26,"hash":"IPgBAoJVdauz6giWSnhgCYk="},{"w":92,"h":76,"hash":"IPiBBIAHrET8bFvpCZQdZQubVvO0vEV5DQ=="},{"w":85,"h":99,"hash":"3wcCBgB4L9nVxUhpWUyqb76n4LjPOfw="},{"w":79,"h":72,"hash":"4AeCBYAHWneox5/01wN7aHnvSAZscNB3qA=="},{"w":76,"h":18,"hash":"4PcBCoL6mZdmelSFz5UU9EQ="},{"w":78,"h":90,"hash":"4PeBBAAIDnhKoKRQpgBqMJy+iar8L4pZDg=="},{"w":72,"h":13,"hash":"3+cBCYKsdkJZyx6/lICO+8I="},{"w":78,"h":19,"hash":"H/iBAYAImX9hwHSeDXImENV3UIN1hMc="},{"w":29,"h":34,"hash":"IAgCDgB+WIp4KreKyhKJX6mqKKCvEI0="},{"w":63,"h":43,"hash":"H/iBA4AI++eiSIlZkLl/ZwKZhrlnSqg="},{"w":19,"h":79,"hash":"3wcCCgD1k3t0hnYqBHm6Di4="},{"w":82,"h":44,"hash":"3/eBA4AIqi2AjW6pr5YLmwF+OlNou7s="},{"w":70,"h":24,"hash":"3wcCCoAyRWCKl2Z3kHa6A1o="},{"w":28,"h":93,"hash":"4PeBCgAHwrsHVqZgqIePdy6kkJh4RRY="},{"w":44,"h":28,"hash":"H/gBBIJ2xMevRKKpZ4bnUTD6hQ=="},{"w":77,"h":9,"hash":"HwiCCYIIxixdZgmgw/m6OIcDrDM6WZ0="},{"w":59,"h":39,"hash":"H/gBBYKlW6qoh11be/NmovTQCbYc"},{"w":9,"h":93,"hash":"4PeBAQIHFVn4p5mIv/DH95KWbUXgUOk="},{"w":9,"h":96,"hash":"3wcCAQKKqluVczewLNPP0G8="},{"w":54,"h":83,"hash":"IPiBAwAIZVRaj5dvnH4NiFfGpfFPxjg="},{"w":75,"h":3,"hash":"3/cFEYR4iPWKmIiV99OsmPc="},{"w":96,"h":42,"hash":"4PeBAoAHcJqRe3qfQ6f3THWIkFi2ivY="},{"w":74,"h":8,"hash":"4PcBCYJ8JYC0NJh5+a5HnaA="},{"w":2,"h":95,"hash":"oPeBCQR3f82Im7eL3/dIdXgHiHiHiHc="},{"w":40,"h":25,"hash":"3/cBDIJGPXu1PFWvGyI2SH8JWA=="},{"w":48,"h":56,"hash":"H/iBDAAHU4OHDHmKpoT1qngAiHZWl2d4CQ=="},{"w":71,"h":18,"hash":"3/cBCoBFzceDtlBaL20Z8Ok="},{"w":60,"h":44,"hash":"3/eBBIAHqreoZmb9ePMT+YS4hIZZmncHCg=="},{"w":42,"h":25,"hash":"3/cBDIKPdrNMd9eNmFlSn7SZCw=="},{"w":64,"h":67,"hash":"IPiBBQAHbSpXaJBWxEgLybIIa6jFdgxmsw=="},{"w":74,"h":85,"hash":"4AcCBgCVe0R0W1l7eTSq0/dH+zScBSU="},{"w":42,"h":71,"hash":"4AeCAwAIZyCedbhPdZlPiaKXHQRSNeA="},{"w":61,"h":34,"hash":"H/gBBII/nL94LK58lLTVCUz9Jw=="},{"w":26,"h":66,"hash":"4AeCCgAISob0BHp4m1BvNhVnCiiFAtM="},{"w":17,"h":62,"hash":"IAgCCgI8jI9nEofooGprka8="},{"w":58,"h":90,"hash":"IAiCAwAI+Rt0bsP6o/o02aef81rcY4g="},{"w":41,"h":96,"hash":"H/gBAwDLuolgvElQHQppYPY="},{"w":57,"h":6,"hash":"IPiBCYIIh0q8dn/PhQBnd8ApWKNbdKo="},{"w":38,"h":47,"hash":"4PcBDgI6lpeRR2aABrhXvWW0UNaEIHg="},{"w":76,"h":6,"hash":"3xeCCYAISIuiIk91a6CpEKQVvUdsBYc="},{"w":85,"h":66,"hash":"H/gBBYDvamWWd5h1Vo2YlvdTB6/w"},{"w":7,"h":52,"hash":"4AeCCQII+ZuGYmj6XPaXdqa3B0a2dNA="},{"w":1,"h":4,"hash":"Hhk+UjgIh2iYh4iYGIuPh/g="},{"w":19,"h":26,"hash":"3weCDAIHY2JJumCmuqb1ToC4Yy94dxtUBw=="},{"w":63,"h":98,"hash":"3/cBBQBSUpp6Kn2Gnn+ikgXX5EBM"},{"w":36,"h":27,"hash":"IPiBBIAHh6xAemiM6Of5b5+q+XOJtpUwBg=="},{"w":69,"h":50,"hash":"IAgCBYK2ilWImIhgd8ikFPeoVvSn"},{"w":17,"h":100,"hash":"HwiCAQAI8el0tLUJG2OAFon6I0mAZ8U="},{"w":59,"h":24,"hash":"4AcCC4Inv4c4e2OIYDUGV7g="},{"w":57,"h":48,"hash":"3/eBBIAHBXh6Y4qllpB1ryKBqvqpSomhDA=="},{"w":90,"h":41,"hash":"IAgCC4CidBWGNweJm6+coBY="},{"w":68,"h":3,"hash":"oBiCCYII8hsmqlAgxcXwJHh2mAVZhWc="},{"w":77,"h":25,"hash":"4PcBCoL0iEqFdXO2zo+n9Eo="},{"w":50,"h":41,"hash":"HwiCBIAHSK9XhZbW6qiAX4j+o1Yn5JPmBA=="},{"w":95,"h":87,"hash":"3/cBBoBbqfUutpKElbmKm4B0C4I0ePA="},{"w":19,"h":7,"hash":"H/iBCoIYmUl/V1VESZ+mA6eLeHenBnY="},{"w":62,"h":28,"hash":"3wcCC4JqV4G1jAeTn3ttsvs="},{"w":80,"h":84,"hash":"HwiCBQAItaKauK+3WPxqSRf6TQlTiYgs1w=="},{"w":58,"h":11,"hash":"IPgBCYJWdVboUvKJ+nc6wI4="},{"w":62,"h":1,"hash":"oRjCKYZ3iIiIiHCXiJCLCIeIiIiAd/g="},{"w":83,"h":84,"hash":"IAgCBwCXsHOreqRrykpmV4t8lq9nsqEP"},{"w":38,"h":50,"hash":"HwiCBAAIMJSGqHiF+EsGGyZ/MLJmynBjAA=="},{"w":90,"h":25,"hash":"4AcCAoAmQEenlsuKgE4WyvY="},{"w":44,"h":34,"hash":"4AeCDIAIlUgqYFOZB1WIemC1gMKUhIq2Bg=="},{"w":44,"h":58,"hash":"4AcCDQJ1xMubx4YIEjhy2ct7H/IW"},{"w":53,"h":97,"hash":"3/eBAwAIfvkuWL5QdinGj6Tb3EAkpKk="},{"w":46,"h":78,"hash":"3wcCBAD69gtnSeOmSQvGoFC4QA=="},{"w":1,"h":91,"hash":"Xui9GQR3CIiHeIgIiXhfqAj3h4iHiHg="},{"w":62,"h":53,"hash":"HwgCBoDPnGynWGlHeHhaWbZ4ymtwP7k="},{"w":31,"h":16,"hash":"HwiCC4IHYMrnVnx9T5138ke3RXBlSHs="},{"w":79,"h":89,"hash":"3wcCBgCwS65nmVZ1Z3pFOze0G4xfP4U="},{"w":88,"h":46,"hash":"HwiCA4AIeft1qUicgPivQEqYkHp7hEY="},{"w":42,"h":42,"hash":"IAgCBwJd0ybqWZCUd6pdbkhHCZnq9ZoE"},{"w":33,"h":75,"hash":"4AeCAgAIin9mWYoCq51AulUCcmlfBB0="},{"w":74,"h":1,"hash":"3ic+AYp4h4eIiAiHD4hcjQg="},{"w":55,"h":35,"hash":"4AeCA4AIeIClN5NyENOgNZyaiIljBmw="},{"w":2,"h":11,"hash":"HlgKIQZwiHh0aZdocCUKd0w="},{"w":29,"h":33,"hash":"IPiBDAIH9lRItpqqd6CWmKAo+ad49JdoCg=="},{"w":63,"h":40,"hash":"HwgCBIBmUIs2Vol2OVgfOEvSHw=="},{"w":77,"h":90,"hash":"4PeBBAAINniEP5lGAIykwV9XCmS5VmjHCg=="},{"w":78,"h":14,"hash":"3wcCCYBGGoFHx5agj3xrR+8="},{"w":37,"h":40,"hash":"4PeBBQAHy1gEeihmdftWmwDn5DhniHAnSA=="},{"w":79,"h":27,"hash":"H/gBCoCc92ZmanaKgGcGl5k="},{"w":32,"h":86,"hash":"3weCAgAHW6NocGpT+AjOVShetbR/aso="},{"w":14,"h":30,"hash":"H/gBCwLNrIeFxaZPd6YvX1o="},{"w":33,"h":30,"hash":"oAeCBYIHarRwplZVhZj915j1dwd5Y352aQ=="},{"w":62,"h":95,"hash":"H/gBBQB5kGdZ1udkd3lJiV2PbAVc"},{"w":38,"h":93,"hash":"3weCAgAH5D7FRwOFXbAZ+fVpeYk0iHY="},{"w":47,"h":32,"hash":"3/cBDYJ0r1iqSJRkd6p3V3s/a9B/"},{"w":24,"h":9,"hash":"3weCCoIXijxlaV+Eb3RI9odfWVeFVEo="},{"w":17,"h":54,"hash":"4PcBAgSYZHWYgGhbIwMCqHU="},{"w":8,"h":78,"hash":"X/iBAQIIiFJTAjdPiZYEl2PU18bU8nQ="},{"w":8,"h":29,"hash":"HvgBCgYccKvOOXhJ20mAnyg="},{"w":90,"h":54,"hash":"H/iBA4AIb+2Md4RWn/Kfe/FJlyZ1jaQ="},{"w":88,"h":16,"hash":"H/gBCYBnc+aN1jVQYFe1Ov4="},{"w":38,"h":43,"hash":"IAiCBAAHzaaMBvyTQkB3gJ94bziEt4h4AQ=="},{"w":45,"h":33,"hash":"H/gBDYLClnt/2qJ5pLuZZpLzfW+Z"},{"w":9,"h":54,"hash":"3weCAQIIGp+aj3Wd9/GWhYjzxmlbRNg="},{"w":97,"h":47,"hash":"HwgCC4DRtDd8iLn5CHJ3Dnc="},{"w":69,"h":50,"hash":"HwiCBIAHyWepeKMEBzTqh8/5k21oaT90Bw=="},{"w":13,"h":65,"hash":"3/cBCQJpo8dpn410PLfAMvg="},{"w":25,"h":30,"hash":"IPiBBAIHqzf2RJS69XfSTG8Ld2qHiYe2Bw=="},{"w":35,"h":77,"hash":"IPgBAwC/xkaVdZYoy6K/wGU="},{"w":71,"h":99,"hash":"3/eBBAAI8jHjH4eYVgrLOHcQSAdCRrQ3Cw=="},{"w":51,"h":61,"hash":"HwgCDgB4ioRm1K+IiVmHolm68zhYANY="},{"w":99,"h":81,"hash":"4AeCBIAH/JSLl4h51voehr91sfZoWH5pBw=="},{"w":58,"h":89,"hash":"4AcCBQAN9jWNunlhuGOXV/qTrGQG"},{"w":92,"h":16,"hash":"H/iBAYAHzWadOEC11RAUXzefbZKQ2lw="},{"w":20,"h":9,"hash":"3vcBC4R6lm+5cB20+YmS1Ag="},{"w":87,"h":10,"hash":"3+eBAYII7ZVjl0/0dZfQebdwhKU8pfo="},{"w":59,"h":3,"hash":"nwcCCYIU+4fttp4sIM3/v4Y="},{"w":13,"h":39,"hash":"H/iBCgII915YU3MIrA+InAykfK6VeHk="},{"w":22,"h":87,"hash":"3/cBCgJ3MMpXK6a/3GXAf2Y="},{"w":20,"h":61,"hash":"H/iBCgIHcMhmxIC4f1uGBYYJhDl0p9c="},{"w":66,"h":33,"hash":"HwgCBIIVdWCYY5inCWn6Zwzbhg=="},{"w":24,"h":56,"hash":"HwiCAgAIV60ZefWzRA93WHxypWdW1wc="},{"w":36,"h":4,"hash":"HvgBGYaQohaH1cJad41gsFs="},{"w":93,"h":76,"hash":"IPiBBIAIcF8CZi2l97tl7aDCr7ubuXQ4Bg=="},{"w":50,"h":49,"hash":"IAgCB4K8Q4h4hHd5l/iUSzW62DWwG5AE"},{"w":89,"h":33,"hash":"H/iBAoAItPphyoZ4+FLXCCappHhPZLM="},{"w":88,"h":38,"hash":"4PcBA4Dgi0XjhoOT8obwtio="},{"w":92,"h":5,"hash":"4PeBAYII9+Mpe4oUCPQYNXqZtjd56L8="},{"w":16,"h":43,"hash":"HwgCCwLaREjvVTaWYGcna/o="},{"w":43,"h":25,"hash":"H/iBC4IIh7kDmbD1XptgRBwqiJg/xcs="},{"w":23,"h":15,"hash":"IAgCFYLE742XWmeuhhpXpJRT8NAa"},{"w":81,"h":7,"hash":"IPiBAYII/XuJaMxPhJlP16ewLUeXWQQ="},{"w":43,"h":10,"hash":"4PcBCoLS3wRpnEkUcvkwb3Q="},{"w":40,"h":31,"hash":"4PeBBIAHbvoid+O8vCBou5CEhcBWSHukBg=="},{"w":2,"h":54,"hash":"XggCGQh2P1eHhUhzil+FCIM="},{"w":81,"h":51,"hash":"HwiCA4AIXq5fhicHMAp5mmqAO6paeLQ="},{"w":66,"h":34,"hash":"3wcCBIKiaF02Rp91WXV/yMDyNw=="},{"w":82,"h":74,"hash":"4PeBBYAIuB98SKHF1VCErjT2iqiJh49Jlw=="},{"w":68,"h":37,"hash":"H/gBBICMWJmAh1d2Z4isj9tQYw=="},{"w":97,"h":1,"hash":"nxe+GYR3d3iIiIBYh8+FCId4iIiAd/g="},{"w":35,"h":74,"hash":"IAgCAwJaeHZYpGRvC0sBaJg="},{"w":84,"h":16,"hash":"4AeCAYIHtlwCmBe5QIZI/dVrfKfVQJM="},{"w":25,"h":39,"hash":"HwgCDAJR+5x4R7xqGLm9dQAwSA=="},{"w":7,"h":89,"hash":"3weCAQIH71t0jh3R8bisB2CGlqVZa/w="},{"w":11,"h":93,"hash":"4PcBEQIIiUqUVniGpkCGDmY="},{"w":28,"h":16,"hash":"3/eBC4AHZgaLiVrzW/ntHXFCuDtPelc="},{"w":47,"h":1,"hash":"4OdBGYaHiHiIiAh4+ojcfPg="},{"w":83,"h":79,"hash":"4PeBBYAHWU/JlHJmlyqL8DdUi9V3hwhJZQ=="},{"w":31,"h":58,"hash":"3/cBDACMjykH1lXXk4f1m1lXwA=="},{"w":87,"h":27,"hash":"3/eBAoAIWAuadHnSkGf6ZDCpWDbDVHk="},{"w":16,"h":25,"hash":"HxgCDALVgxjVRLTpv0gXoIL7lg=="},{"w":95,"h":6,"hash":"3/eBAYAHNhipdw3ol29wPIUrqUSnB0Y="},{"w":40,"h":79,"hash":"3/cBBACXn5bth4n5V0ifeYb4CA=="},{"w":37,"h":88,"hash":"3/eBAgAHatYMrGagPXqAUTT0aZlkV6k="},{"w":7,"h":64,"hash":"H/gBCQI1U92VyEzQtwv3UiI="},{"w":73,"h":44,"hash":"4AeCA4AI27s6T2uqZi+wyMCreGFnrNs="},{"w":13,"h":53,"hash":"H/gBCgKVPabFiVmfjzmJPgc="},{"w":26,"h":88,"hash":"4AeCAQAH+JrYmobr0Ntl9nJ0DUiHpaU="},{"w":92,"h":96,"hash":"H/gBBwDjZwxVe4qVdpSGh3maxpnPRPAB"},{"w":22,"h":93,"hash":"IPiBAQAIem4GeFLLT5awR4mPYXKA5Tc="},{"w":11,"h":63,"hash":"3/cBCQIEtyd3dmiLRl8zsW8="},{"w":1,"h":16,"hash":"IUbCgRB3CIiHeHj3iIhgOAj3homHiJg="},{"w":86,"h":54,"hash":"3wcCBIBmpUU+NtE5BapGT91XLw=="},{"w":45,"h":45,"hash":"3/eBBQAIjRSWIMBjFweKEy/f4ohbs78Dtw=="},{"w":86,"h":72,"hash":"IPgBBoDpdbynqIevhmp4hEio9NP2gBk="},{"w":56,"h":61,"hash":"HwiCBQAH/ErFesNrqQBrC8foSKc/e7px+A=="},{"w":36,"h":82,"hash":"IPgBAwDXSB9klzdqJkDSCJM="},{"w":19,"h":26,"hash":"IPiBBAIHimHC+WqsTSHQA8CFXPR1aWR7Bw=="},{"w":63,"h":30,"hash":"4AcCC4I3YoJa+ltaWY+Hed8="},{"w":22,"h":67,"hash":"H/iBCgAIdFg69+dzCKkNMJDF6aaPSyc="},{"w":7,"h":4,"hash":"XggGJIgRdYiZWVxIaK+wffh5Sg=="},{"w":65,"h":44,"hash":"H/iBA4AI2jgH3cmnUEkHdX5wtrmFaHQ="},{"w":97,"h":19,"hash":"HwgCCYBPdSVmN45n94dKaAM="},{"w":67,"h":11,"hash":"HwiCCYIImJywpza6bza/d0WndXmo+Dc="},{"w":83,"h":76,"hash":"3wcCBoAIXnqYdVekp+pHk3pn3blfP2g="},{"w":80,"h":98,"hash":"IAiCBAAI39GbiZdfdejP/6+SXIhJSPW3BA=="},{"w":72,"h":86,"hash":"3/cBBgKS+2i4h4h1a46YhnlKwb9G+qc="},{"w":78,"h":29,"hash":"3weCAoAIY2hKCQcA2gp4mpvrhouJX2c="},{"w":91,"h":1,"hash":"IfhBEYZ3iIeIiAiIB4j1i4g="},{"w":68,"h":99,"hash":"IAiCAwAIvewBFkMMqfxbLHqvcVbUZms="},{"w":79,"h":4,"hash":"4AcCCYSbW4Z9nYBL6TpgcE0="},{"w":80,"h":44,"hash":"IAiCA4IHxb+flWn5zAqJiPi0z6Vxk5g="},{"w":43,"h":36,"hash":"4PcBDoC6Qb9xjJWgeX5rc6hoklAaxH8="},{"w":61,"h":21,"hash":"4PeBAoAHoJ2KVFQu/C75JX+S/veGdqI="},{"w":23,"h":93,"hash":"4AcCCgKo/IiJRxNo2ZmfDd0="},{"w":96,"h":65,"hash":"HwiCA4AIgHXGaTw2+itpEHn4d5dxd6c="},{"w":90,"h":27,"hash":"IPgBCoBhgm/V6idYX+m39Sg="},{"w":81,"h":31,"hash":"3weCAoAHn6dXpYipr98KiLRmcGlrmIc="},{"w":73,"h":41,"hash":"3/cBBIA25bcwaFfYQdYCSqbxlg=="},{"w":91,"h":68,"hash":"3/eBBIAI0Jd2OHJIevfJgHx8NNZGpwl/CA=="},{"w":94,"h":74,"hash":"IPgBBoBvTGmIeYhay4x3Z3U3pn8gD0I="},{"w":72,"h":34,"hash":"3weCAoAHQIe83bnPpAqzRjjYpIKvlzc="},{"w":51,"h":34,"hash":"HwgCBYA6fzbdPH26bIaJuMpavwGt"},{"w":55,"h":33,"hash":"H/iBA4AHNLA1nUbPzZ4NNVOQpXiZ11k="},{"w":92,"h":20,"hash":"4PcBAoL2t02KyoishKT/vWc="},{"w":1,"h":94,"hash":"Hvi9CQKICId4iIgYj3tPOAj3h4iHiHg="},{"w":46,"h":34,"hash":"HwgCDYCYTefPhieK2bw3PMCa2Lrw"},{"w":64,"h":30,"hash":"IAiCAoAHTDx/WamEcJBD9saIKnufjmc="},{"w":36,"h":19,"hash":"HwgCDIKHuMmduHeamoBgk/PTeg=="},{"w":90,"h":94,"hash":"IAiCBQAHZy+dmah1KFYwiwVXfURlxoBptQ=="},{"w":89,"h":49,"hash":"H/gBBIBpkIl5rJU5yJrcT/x9UQ=="},{"w":34,"h":2,"hash":"Y8iBCYSIVbsJgXeT+wc2iYeHh4eHiAc="},{"w":25,"h":40,"hash":"3wcCDAKkNHd3x4wKpngw3Pa3Cw=="},{"w":47,"h":32,"hash":"IPiBA4AHl3CHY4VS/WGvOem9n4xZNiY="},{"w":53,"h":96,"hash":"4PcBBAB4zFkHVnZ5Vnb+cQoyqA=="},{"w":47,"h":93,"hash":"4PeBAwAHovPYd5nJhr9y84volUge9qQ="},{"w":86,"h":89,"hash":"4AcCBwIxnclaVpZmhwuXeIqsOUOABWQF"},{"w":40,"h":90,"hash":"IPiBAgAIzWDGoI4lL8cOkpTyeDiGmHY="},{"w":38,"h":66,"hash":"H/gBBAK5wb10WqSEx3BqgHasQA=="},{"w":12,"h":21,"hash":"4AeCAwIHU5iHp/TaqbBfi7ypznOSRIA="},{"w":54,"h":29,"hash":"3/cBBILTCdd3yod7eIVwrqfOnw=="},{"w":78,"h":77,"hash":"3weCBYAIfO9WhGEMTcBzWAd3QJc6hLVohw=="},{"w":88,"h":96,"hash":"3/cBBgCfiF9LQRun2ME2UJ5yuGC1Dgg="},{"w":49,"h":89,"hash":"4PeBAwAIKc/XsRei+QLKXFcyRHdFSvM="},{"w":19,"h":41,"hash":"3wcCCwKIj4pZgGhJXAfKXfQ="},{"w":70,"h":53,"hash":"IPiBBIII2CpoSAcGTfhW94S4b3aFdlqmBQ=="},{"w":55,"h":57,"hash":"4PcBBwAGraR0aVs6blrol9aYrApGsBgG"},{"w":54,"h":29,"hash":"3/eBC4AHyLBqhpD4VxQX9hqwzY9DuVg="},{"w":15,"h":75,"hash":"HwgCCQKJ9qeHapW2EDvPhPc="},{"w":93,"h":81,"hash":"H/iBBIAHC2WedpVnfngIVnX/YBI7RopLBw=="},{"w":64,"h":96,"hash":"4AcCBQCMiJ5GnFu6rDv1yHqfSLQP"},{"w":76,"h":44,"hash":"IPiBA4AHqQeWWI2puG+/SsVZfH+EKFc="},{"w":88,"h":33,"hash":"IAgCA4L4N5JQnp1odH8Id50="},{"w":1,"h":34,"hash":"Xue9MQaICIh3iHj3h3WfiAj3h4iId4g="},{"w":30,"h":4,"hash":"IOgFGYa2AneplIqU+YGncKc="},{"w":92,"h":76,"hash":"HwiCBIAHKrOomZBJlckKC2XFT54JnMSLCA=="},{"w":91,"h":16,"hash":"IPgBCYLldw2ryLmLYHjxVkk="},{"w":3,"h":26,"hash":"3yeGCQIYiY/WpUpL9DmJ/2WYd4anCrk="},{"w":2,"h":14,"hash":"YNcFCQ56SgeK1ld+hvp2+FY="},{"w":93,"h":90,"hash":"IPiBBYAHlR+Hm5gJyPdlAqqImTuPWXlFeA=="},{"w":47,"h":88,"hash":"3/cBBAALd4h3lHZpeIlfs6NTDQ=="},{"w":35,"h":32,"hash":"4AeCBYIHdgJPGVxze8rQrvpIvplllljXBw=="},{"w":38,"h":5,"hash":"4AcCCYRTKNxEawW3UJsl/5Y="},{"w":8,"h":78,"hash":"H/iBCQIHjIUIg8cEM2XQxWp6oKdah5A="},{"w":39,"h":36,"hash":"3/cBDoJQZreZx2RPmKWGpxpkSXA6/EM="},{"w":55,"h":13,"hash":"HwiCAYAHfuUceQxcnO/ZMFE234F3aZY="},{"w":13,"h":56,"hash":"IAgCCgJ7gviHc4ur2H4A1gI="},{"w":26,"h":22,"hash":"3weCBIIHx2iLhiCf+OrZYBToaS9I6Zd1BA=="},{"w":83,"h":19,"hash":"3wcCCoL5dJtjKLdzdZvA/G0="},{"w":7,"h":9,"hash":"IRiCDAQXc8cFtlkd8SmVQIVf+rcJWDm7BA=="},{"w":90,"h":43,"hash":"4PcBA4BWXGyC3oWPkJfDrgo="},{"w":43,"h":77,"hash":"IPiBAwAHhT8asHZ/VIr3tMogVkNnm1g="},{"w":36,"h":27,"hash":"IPgBBYKokWWJhMqLlilFBLsGpL/F"},{"w":86,"h":62,"hash":"3weCBIAHsSsGVZlmSOT8f6tyeRCPwHh6DA=="},{"w":63,"h":89,"hash":"IAgCBQC69pVJd3nafFOo1Xr893oE"},{"w":69,"h":98,"hash":"4AeCBAAHidVTQLuXiQbeVJ9H32iHhUaHAg=="},{"w":12,"h":58,"hash":"HwgCCQKZX0iHh3rGb6X1Nec="},{"w":7,"h":7,"hash":"YPeFFQYXh8baRainTwmCfET7/Km5ia0jxw=="},{"w":40,"h":12,"hash":"IPgBEoSX8Ul3WeSbcLd1Zfg="},{"w":93,"h":47,"hash":"4PeBA4AHY4N7pPu7oJO5AnmeQLYcRLk="},{"w":15,"h":42,"hash":"4AcCCwJ0D/zATmOWM/r3xXM="},{"w":67,"h":45,"hash":"3/eBA4AHVbr4h517mv+eZprFsJioaoQ="},{"w":67,"h":74,"hash":"IAgCBgBqmUc1R2d6iqtrigl4lSf/VXQ="},{"w":29,"h":67,"hash":"3weCAgAHYJRmaGqgmaOAV5q0AZR2akk="},{"w":43,"h":10,"hash":"4AcCCoL8SVDXdVsyiECVaK8="},{"w":75,"h":40,"hash":"IAiCA4AHw2mXw6/AoA5Eo8onAwY4s2o="},{"w":73,"h":91,"hash":"3wcCBgDFhsbJlnmDaYxwRZZUl5GAfzY="},{"w":21,"h":54,"hash":"4AeCAgIHWamZCKf0fNZpTyw6aHxHXzM="},{"w":98,"h":52,"hash":"H/gBBICZZsltiKuMUHewoYEI5g=="},{"w":2,"h":29,"hash":"XwiCCQR3cpuYwKqODEIHWXgHiHiIiIc="}] diff --git a/core/artwork/thumbhash/testdata/golden.json b/core/artwork/thumbhash/testdata/golden.json deleted file mode 100644 index 578986e75..000000000 --- a/core/artwork/thumbhash/testdata/golden.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "alpha.png": "JOiFBQ4nkIexh3p4iA8uB+lYhIeAh3d4dw==", - "landscape.png": "3wcOFJpwh4eBh3d4iIePgAj3hw==", - "portrait.png": "3/cNFBqBB4iId4d3d4iAjwj4hw==", - "solid.png": "HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA", - "square.png": "H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I", - "tiny.png": "HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP" -} diff --git a/core/artwork/thumbhash/testdata/landscape.png b/core/artwork/thumbhash/testdata/landscape.png deleted file mode 100644 index 413851f47..000000000 Binary files a/core/artwork/thumbhash/testdata/landscape.png and /dev/null differ diff --git a/core/artwork/thumbhash/testdata/portrait.png b/core/artwork/thumbhash/testdata/portrait.png deleted file mode 100644 index bc6e5f0de..000000000 Binary files a/core/artwork/thumbhash/testdata/portrait.png and /dev/null differ diff --git a/core/artwork/thumbhash/testdata/solid.png b/core/artwork/thumbhash/testdata/solid.png deleted file mode 100644 index 3c53a36bf..000000000 Binary files a/core/artwork/thumbhash/testdata/solid.png and /dev/null differ diff --git a/core/artwork/thumbhash/testdata/square.png b/core/artwork/thumbhash/testdata/square.png deleted file mode 100644 index a81000cbc..000000000 Binary files a/core/artwork/thumbhash/testdata/square.png and /dev/null differ diff --git a/core/artwork/thumbhash/testdata/thumbhash.js b/core/artwork/thumbhash/testdata/thumbhash.js deleted file mode 100644 index 369b118ec..000000000 --- a/core/artwork/thumbhash/testdata/thumbhash.js +++ /dev/null @@ -1,288 +0,0 @@ -/** - * Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A. - * - * @param w The width of the input image. Must be ≤100px. - * @param h The height of the input image. Must be ≤100px. - * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. - * @returns The ThumbHash as a Uint8Array. - */ -export function rgbaToThumbHash(w, h, rgba) { - // Encoding an image larger than 100x100 is slow with no benefit - if (w > 100 || h > 100) throw new Error(`${w}x${h} doesn't fit in 100x100`) - let { PI, round, max, cos, abs } = Math - - // Determine the average color - let avg_r = 0, avg_g = 0, avg_b = 0, avg_a = 0 - for (let i = 0, j = 0; i < w * h; i++, j += 4) { - let alpha = rgba[j + 3] / 255 - avg_r += alpha / 255 * rgba[j] - avg_g += alpha / 255 * rgba[j + 1] - avg_b += alpha / 255 * rgba[j + 2] - avg_a += alpha - } - if (avg_a) { - avg_r /= avg_a - avg_g /= avg_a - avg_b /= avg_a - } - - let hasAlpha = avg_a < w * h - let l_limit = hasAlpha ? 5 : 7 // Use fewer luminance bits if there's alpha - let lx = max(1, round(l_limit * w / max(w, h))) - let ly = max(1, round(l_limit * h / max(w, h))) - let l = [] // luminance - let p = [] // yellow - blue - let q = [] // red - green - let a = [] // alpha - - // Convert the image from RGBA to LPQA (composite atop the average color) - for (let i = 0, j = 0; i < w * h; i++, j += 4) { - let alpha = rgba[j + 3] / 255 - let r = avg_r * (1 - alpha) + alpha / 255 * rgba[j] - let g = avg_g * (1 - alpha) + alpha / 255 * rgba[j + 1] - let b = avg_b * (1 - alpha) + alpha / 255 * rgba[j + 2] - l[i] = (r + g + b) / 3 - p[i] = (r + g) / 2 - b - q[i] = r - g - a[i] = alpha - } - - // Encode using the DCT into DC (constant) and normalized AC (varying) terms - let encodeChannel = (channel, nx, ny) => { - let dc = 0, ac = [], scale = 0, fx = [] - for (let cy = 0; cy < ny; cy++) { - for (let cx = 0; cx * ny < nx * (ny - cy); cx++) { - let f = 0 - for (let x = 0; x < w; x++) - fx[x] = cos(PI / w * cx * (x + 0.5)) - for (let y = 0; y < h; y++) - for (let x = 0, fy = cos(PI / h * cy * (y + 0.5)); x < w; x++) - f += channel[x + y * w] * fx[x] * fy - f /= w * h - if (cx || cy) { - ac.push(f) - scale = max(scale, abs(f)) - } else { - dc = f - } - } - } - if (scale) - for (let i = 0; i < ac.length; i++) - ac[i] = 0.5 + 0.5 / scale * ac[i] - return [dc, ac, scale] - } - let [l_dc, l_ac, l_scale] = encodeChannel(l, max(3, lx), max(3, ly)) - let [p_dc, p_ac, p_scale] = encodeChannel(p, 3, 3) - let [q_dc, q_ac, q_scale] = encodeChannel(q, 3, 3) - let [a_dc, a_ac, a_scale] = hasAlpha ? encodeChannel(a, 5, 5) : [] - - // Write the constants - let isLandscape = w > h - let header24 = round(63 * l_dc) | (round(31.5 + 31.5 * p_dc) << 6) | (round(31.5 + 31.5 * q_dc) << 12) | (round(31 * l_scale) << 18) | (hasAlpha << 23) - let header16 = (isLandscape ? ly : lx) | (round(63 * p_scale) << 3) | (round(63 * q_scale) << 9) | (isLandscape << 15) - let hash = [header24 & 255, (header24 >> 8) & 255, header24 >> 16, header16 & 255, header16 >> 8] - let ac_start = hasAlpha ? 6 : 5 - let ac_index = 0 - if (hasAlpha) hash.push(round(15 * a_dc) | (round(15 * a_scale) << 4)) - - // Write the varying factors - for (let ac of hasAlpha ? [l_ac, p_ac, q_ac, a_ac] : [l_ac, p_ac, q_ac]) - for (let f of ac) - hash[ac_start + (ac_index >> 1)] |= round(15 * f) << ((ac_index++ & 1) << 2) - return new Uint8Array(hash) -} - -/** - * Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A. - * - * @param hash The bytes of the ThumbHash. - * @returns The width, height, and pixels of the rendered placeholder image. - */ -export function thumbHashToRGBA(hash) { - let { PI, min, max, cos, round } = Math - - // Read the constants - let header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16) - let header16 = hash[3] | (hash[4] << 8) - let l_dc = (header24 & 63) / 63 - let p_dc = ((header24 >> 6) & 63) / 31.5 - 1 - let q_dc = ((header24 >> 12) & 63) / 31.5 - 1 - let l_scale = ((header24 >> 18) & 31) / 31 - let hasAlpha = header24 >> 23 - let p_scale = ((header16 >> 3) & 63) / 63 - let q_scale = ((header16 >> 9) & 63) / 63 - let isLandscape = header16 >> 15 - let lx = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7) - let ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7) - let a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1 - let a_scale = (hash[5] >> 4) / 15 - - // Read the varying factors (boost saturation by 1.25x to compensate for quantization) - let ac_start = hasAlpha ? 6 : 5 - let ac_index = 0 - let decodeChannel = (nx, ny, scale) => { - let ac = [] - for (let cy = 0; cy < ny; cy++) - for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) - ac.push((((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale) - return ac - } - let l_ac = decodeChannel(lx, ly, l_scale) - let p_ac = decodeChannel(3, 3, p_scale * 1.25) - let q_ac = decodeChannel(3, 3, q_scale * 1.25) - let a_ac = hasAlpha && decodeChannel(5, 5, a_scale) - - // Decode using the DCT into RGB - let ratio = thumbHashToApproximateAspectRatio(hash) - let w = round(ratio > 1 ? 32 : 32 * ratio) - let h = round(ratio > 1 ? 32 / ratio : 32) - let rgba = new Uint8Array(w * h * 4), fx = [], fy = [] - for (let y = 0, i = 0; y < h; y++) { - for (let x = 0; x < w; x++, i += 4) { - let l = l_dc, p = p_dc, q = q_dc, a = a_dc - - // Precompute the coefficients - for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++) - fx[cx] = cos(PI / w * (x + 0.5) * cx) - for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++) - fy[cy] = cos(PI / h * (y + 0.5) * cy) - - // Decode L - for (let cy = 0, j = 0; cy < ly; cy++) - for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++) - l += l_ac[j] * fx[cx] * fy2 - - // Decode P and Q - for (let cy = 0, j = 0; cy < 3; cy++) { - for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) { - let f = fx[cx] * fy2 - p += p_ac[j] * f - q += q_ac[j] * f - } - } - - // Decode A - if (hasAlpha) - for (let cy = 0, j = 0; cy < 5; cy++) - for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) - a += a_ac[j] * fx[cx] * fy2 - - // Convert to RGB - let b = l - 2 / 3 * p - let r = (3 * l - b + q) / 2 - let g = r - q - rgba[i] = max(0, 255 * min(1, r)) - rgba[i + 1] = max(0, 255 * min(1, g)) - rgba[i + 2] = max(0, 255 * min(1, b)) - rgba[i + 3] = max(0, 255 * min(1, a)) - } - } - return { w, h, rgba } -} - -/** - * Extracts the average color from a ThumbHash. RGB is not be premultiplied by A. - * - * @param hash The bytes of the ThumbHash. - * @returns The RGBA values for the average color. Each value ranges from 0 to 1. - */ -export function thumbHashToAverageRGBA(hash) { - let { min, max } = Math - let header = hash[0] | (hash[1] << 8) | (hash[2] << 16) - let l = (header & 63) / 63 - let p = ((header >> 6) & 63) / 31.5 - 1 - let q = ((header >> 12) & 63) / 31.5 - 1 - let hasAlpha = header >> 23 - let a = hasAlpha ? (hash[5] & 15) / 15 : 1 - let b = l - 2 / 3 * p - let r = (3 * l - b + q) / 2 - let g = r - q - return { - r: max(0, min(1, r)), - g: max(0, min(1, g)), - b: max(0, min(1, b)), - a - } -} - -/** - * Extracts the approximate aspect ratio of the original image. - * - * @param hash The bytes of the ThumbHash. - * @returns The approximate aspect ratio (i.e. width / height). - */ -export function thumbHashToApproximateAspectRatio(hash) { - let header = hash[3] - let hasAlpha = hash[2] & 0x80 - let isLandscape = hash[4] & 0x80 - let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7 - let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7 - return lx / ly -} - -/** - * Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by - * A. This is optimized for speed and simplicity and does not optimize for size - * at all. This doesn't do any compression (all values are stored uncompressed). - * - * @param w The width of the input image. Must be ≤100px. - * @param h The height of the input image. Must be ≤100px. - * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. - * @returns A data URL containing a PNG for the input image. - */ -export function rgbaToDataURL(w, h, rgba) { - let row = w * 4 + 1 - let idat = 6 + h * (5 + row) - let bytes = [ - 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, - w >> 8, w & 255, 0, 0, h >> 8, h & 255, 8, 6, 0, 0, 0, 0, 0, 0, 0, - idat >>> 24, (idat >> 16) & 255, (idat >> 8) & 255, idat & 255, - 73, 68, 65, 84, 120, 1 - ] - let table = [ - 0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, - 1342533948, -306674912, -267414716, -690576408, -882789492, -1687895376, - -2032938284, -1609899400, -1111625188 - ] - let a = 1, b = 0 - for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) { - bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0) - for (b = (b + a) % 65521; i < end; i++) { - let u = rgba[i] & 255 - bytes.push(u) - a = (a + u) % 65521 - b = (b + a) % 65521 - } - } - bytes.push( - b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0, - 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130 - ) - for (let [start, end] of [[12, 29], [37, 41 + idat]]) { - let c = ~0 - for (let i = start; i < end; i++) { - c ^= bytes[i] - c = (c >>> 4) ^ table[c & 15] - c = (c >>> 4) ^ table[c & 15] - } - c = ~c - bytes[end++] = c >>> 24 - bytes[end++] = (c >> 16) & 255 - bytes[end++] = (c >> 8) & 255 - bytes[end++] = c & 255 - } - return 'data:image/png;base64,' + btoa(String.fromCharCode(...bytes)) -} - -/** - * Decodes a ThumbHash to a PNG data URL. This is a convenience function that - * just calls "thumbHashToRGBA" followed by "rgbaToDataURL". - * - * @param hash The bytes of the ThumbHash. - * @returns A data URL containing a PNG for the rendered ThumbHash. - */ -export function thumbHashToDataURL(hash) { - let image = thumbHashToRGBA(hash) - return rgbaToDataURL(image.w, image.h, image.rgba) -} diff --git a/core/artwork/thumbhash/testdata/tiny.png b/core/artwork/thumbhash/testdata/tiny.png deleted file mode 100644 index ab635e11a..000000000 Binary files a/core/artwork/thumbhash/testdata/tiny.png and /dev/null differ diff --git a/core/artwork/thumbhash/thumbhash.go b/core/artwork/thumbhash/thumbhash.go deleted file mode 100644 index a255bec42..000000000 --- a/core/artwork/thumbhash/thumbhash.go +++ /dev/null @@ -1,257 +0,0 @@ -// Package thumbhash implements the ThumbHash encoding algorithm (https://github.com/evanw/thumbhash). -package thumbhash - -import ( - "errors" - "image" - "image/draw" - "math" - - xdraw "golang.org/x/image/draw" -) - -// maxInputSize is the algorithm's hard limit: larger inputs are rejected by every implementation. -const maxInputSize = 100 - -// term is one DCT coefficient's frequency pair, in the reference's triangular scan order. -type term struct{ cx, cy int } - -// Encode returns the ThumbHash of img: 24 bytes when opaque, 25 with alpha. Output matches -// evanw/thumbhash except where a coefficient lands on a quantization tie, where a nibble may differ by 1. -func Encode(img image.Image) ([]byte, error) { - rgba := toNRGBA(downscale(img)) - b := rgba.Bounds() - w, h := b.Dx(), b.Dy() - if w == 0 || h == 0 { - return nil, errors.New("thumbhash: empty image") - } - - avgR, avgG, avgB, avgA := averageColor(rgba, w, h) - hasAlpha := avgA < float64(w*h) - if avgA > 0 { - avgR /= avgA - avgG /= avgA - avgB /= avgA - } - - lLimit := 7.0 - if hasAlpha { - lLimit = 5.0 - } - maxWH := float64(max(w, h)) - lx := max(1, int(math.Round(lLimit*float64(w)/maxWH))) - ly := max(1, int(math.Round(lLimit*float64(h)/maxWH))) - - lTerms := terms(max(3, lx), max(3, ly)) - pTerms := terms(3, 3) - qTerms := terms(3, 3) - var aTerms []term - if hasAlpha { - aTerms = terms(5, 5) - } - - // The widest coefficient region wins: 3x3 chroma, 5x5 alpha, and luma's own lx by ly. - chan5 := 3 - if hasAlpha { - chan5 = 5 - } - nx := max(max(3, lx), chan5) - cosX := cosTable(nx, w) - cosY := cosTable(max(max(3, ly), chan5), h) - - lAcc := make([]float64, len(lTerms)) - pAcc := make([]float64, len(pTerms)) - qAcc := make([]float64, len(qTerms)) - aAcc := make([]float64, len(aTerms)) - rowL := make([]float64, nx) - rowP := make([]float64, nx) - rowQ := make([]float64, nx) - rowA := make([]float64, nx) - - for y := range h { - clear(rowL) - clear(rowP) - clear(rowQ) - clear(rowA) - row := rgba.Pix[y*rgba.Stride:] - for x := range w { - j := x * 4 - alpha := float64(row[j+3]) / 255 - r := avgR*(1-alpha) + alpha/255*float64(row[j]) - g := avgG*(1-alpha) + alpha/255*float64(row[j+1]) - bl := avgB*(1-alpha) + alpha/255*float64(row[j+2]) - lv := (r + g + bl) / 3 - pv := (r+g)/2 - bl - qv := r - g - for cx := range nx { - f := cosX[cx][x] - rowL[cx] += lv * f - rowP[cx] += pv * f - rowQ[cx] += qv * f - } - // hasAlpha is loop-invariant, so this costs a predicted branch rather than a quarter - // of the inner loop that opaque images never need. - if hasAlpha { - for cx := range nx { - rowA[cx] += alpha * cosX[cx][x] - } - } - } - accumulate(lAcc, lTerms, rowL, cosY, y) - accumulate(pAcc, pTerms, rowP, cosY, y) - accumulate(qAcc, qTerms, rowQ, cosY, y) - accumulate(aAcc, aTerms, rowA, cosY, y) - } - - n := float64(w * h) - lDC, lAC, lScale := normalize(lAcc, n) - pDC, pAC, pScale := normalize(pAcc, n) - qDC, qAC, qScale := normalize(qAcc, n) - aDC, aAC, aScale := normalize(aAcc, n) - - return pack(w, h, hasAlpha, lx, ly, - lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale, lAC, pAC, qAC, aAC), nil -} - -// terms lists the (cx, cy) pairs of the reference's triangular coefficient region, in write order. -func terms(nx, ny int) []term { - var ts []term - for cy := range ny { - for cx := 0; cx*ny < nx*(ny-cy); cx++ { - ts = append(ts, term{cx, cy}) - } - } - return ts -} - -// cosTable precomputes cos(pi/size * c * (i+0.5)) with the reference's exact expression, so the -// table values are bit-identical to recomputing them per coefficient. -func cosTable(n, size int) [][]float64 { - t := make([][]float64, n) - for c := range n { - t[c] = make([]float64, size) - for i := range size { - t[c][i] = math.Cos(math.Pi / float64(size) * float64(c) * (float64(i) + 0.5)) - } - } - return t -} - -func averageColor(rgba *image.NRGBA, w, h int) (r, g, b, a float64) { - for y := range h { - row := rgba.Pix[y*rgba.Stride:] - for x := range w { - j := x * 4 - alpha := float64(row[j+3]) / 255 - r += alpha / 255 * float64(row[j]) - g += alpha / 255 * float64(row[j+1]) - b += alpha / 255 * float64(row[j+2]) - a += alpha - } - } - return r, g, b, a -} - -// accumulate folds one row's per-cx sums into the term accumulators, so the pixel loop costs -// nx multiplies per pixel instead of one per coefficient. -func accumulate(acc []float64, ts []term, row []float64, cosY [][]float64, y int) { - for k, t := range ts { - acc[k] += row[t.cx] * cosY[t.cy][y] - } -} - -// normalize splits the accumulators into DC and scaled AC terms, matching the reference: a constant -// image leaves scale at 0, which skips normalization rather than mapping the terms to the midpoint. -func normalize(acc []float64, n float64) (dc float64, ac []float64, scale float64) { - if len(acc) == 0 { - return 0, nil, 0 - } - dc = acc[0] / n - ac = make([]float64, len(acc)-1) - for i, v := range acc[1:] { - ac[i] = v / n - scale = max(scale, math.Abs(ac[i])) - } - if scale > 0 { - for i := range ac { - ac[i] = 0.5 + 0.5/scale*ac[i] - } - } - return dc, ac, scale -} - -func pack(w, h int, hasAlpha bool, lx, ly int, - lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale float64, - lAC, pAC, qAC, aAC []float64, -) []byte { - isLandscape := 0 - if w > h { - isLandscape = 1 - } - alphaBit := 0 - if hasAlpha { - alphaBit = 1 - } - header24 := int(math.Round(63*lDC)) | int(math.Round(31.5+31.5*pDC))<<6 | - int(math.Round(31.5+31.5*qDC))<<12 | int(math.Round(31*lScale))<<18 | alphaBit<<23 - lead := lx - if isLandscape == 1 { - lead = ly - } - header16 := lead | int(math.Round(63*pScale))<<3 | int(math.Round(63*qScale))<<9 | isLandscape<<15 - - acs := [][]float64{lAC, pAC, qAC} - acStart := 5 - if hasAlpha { - acs = append(acs, aAC) - acStart = 6 - } - acCount := 0 - for _, ac := range acs { - acCount += len(ac) - } - - hash := make([]byte, acStart+(acCount+1)/2) - hash[0] = byte(header24 & 255) - hash[1] = byte((header24 >> 8) & 255) - hash[2] = byte(header24 >> 16) - hash[3] = byte(header16 & 255) - hash[4] = byte(header16 >> 8) - if hasAlpha { - hash[5] = byte(int(math.Round(15*aDC)) | int(math.Round(15*aScale))<<4) - } - acIndex := 0 - for _, ac := range acs { - for _, f := range ac { - hash[acStart+(acIndex>>1)] |= byte(int(math.Round(15*f)) << ((acIndex & 1) << 2)) - acIndex++ - } - } - return hash -} - -// NRGBA, not RGBA: ThumbHash requires non-premultiplied RGB and the pipeline hands us a -// premultiplied *image.RGBA, which draw.Draw un-premultiplies on the way in. -func toNRGBA(img image.Image) *image.NRGBA { - // Conservative: a sub-image re-slices Pix so the loops would read it correctly too, but the - // copy costs nothing on the origin-anchored images the pipeline actually produces. - if nrgba, ok := img.(*image.NRGBA); ok && nrgba.Rect.Min == (image.Point{}) { - return nrgba - } - b := img.Bounds() - dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) - draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) - return dst -} - -func downscale(img image.Image) image.Image { - b := img.Bounds() - w, h := b.Dx(), b.Dy() - if w <= maxInputSize && h <= maxInputSize { - return img - } - scale := float64(maxInputSize) / float64(max(w, h)) - dst := image.NewNRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) - xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) - return dst -} diff --git a/core/artwork/thumbhash/thumbhash_suite_test.go b/core/artwork/thumbhash/thumbhash_suite_test.go deleted file mode 100644 index 11a6f0e25..000000000 --- a/core/artwork/thumbhash/thumbhash_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package thumbhash_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestThumbHash(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "ThumbHash Suite") -} diff --git a/core/artwork/thumbhash/thumbhash_test.go b/core/artwork/thumbhash/thumbhash_test.go deleted file mode 100644 index 4832c25bc..000000000 --- a/core/artwork/thumbhash/thumbhash_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package thumbhash_test - -import ( - "encoding/base64" - "encoding/json" - "image" - "image/color" - "image/draw" - _ "image/png" - "os" - "path/filepath" - "runtime" - "slices" - - "github.com/navidrome/navidrome/core/artwork/thumbhash" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// testdataDir is resolved via runtime.Caller because tests.Init (thumbhash_suite_test.go) chdirs -// the process to the repo root, which would break a plain relative "testdata" path. -var testdataDir = func() string { - _, file, _, _ := runtime.Caller(0) - return filepath.Join(filepath.Dir(file), "testdata") -}() - -// fixtureImage decodes a testdata PNG. NRGBA, not RGBA: ThumbHash needs non-premultiplied pixels, -// and RGBA would silently premultiply every fixture that has alpha. -func fixtureImage(name string) *image.NRGBA { - GinkgoHelper() - f, err := os.Open(filepath.Join(testdataDir, name)) - Expect(err).ToNot(HaveOccurred()) - defer f.Close() - src, _, err := image.Decode(f) - Expect(err).ToNot(HaveOccurred()) - b := src.Bounds() - dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) - draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src) - return dst -} - -// headerOnlyFixtures have mathematically-zero AC content, so every AC nibble is float rounding -// noise sitting on a quantization tie; only the header bytes carry signal. -var headerOnlyFixtures = []string{"solid.png", "tiny.png"} - -func loadJSON[T any](name string) T { - GinkgoHelper() - data, err := os.ReadFile(filepath.Join(testdataDir, name)) - Expect(err).ToNot(HaveOccurred()) - var out T - Expect(json.Unmarshal(data, &out)).To(Succeed()) - return out -} - -func loadGoldens() map[string]string { - GinkgoHelper() - golden := loadJSON[map[string]string]("golden.json") - Expect(golden).ToNot(BeEmpty()) - return golden -} - -// mix is a stateless 32-bit finaliser matching gen_generated.mjs, so both languages build the -// same synthetic pixels and only the reference's hashes need committing. -func mix(n uint32) uint32 { - n = (n ^ (n >> 16)) * 2246822507 - n = (n ^ (n >> 13)) * 3266489909 - return n ^ (n >> 16) -} - -func generatedImage(i int) *image.NRGBA { - w := 1 + int(mix(uint32(i)*3+1)%100) - h := 1 + int(mix(uint32(i)*3+2)%100) - img := image.NewNRGBA(image.Rect(0, 0, w, h)) - for k := range img.Pix { - img.Pix[k] = byte(mix(uint32(i)*1000003 + uint32(k))) - } - if i%2 == 0 { - for k := 3; k < len(img.Pix); k += 4 { - img.Pix[k] = 255 - } - } - return img -} - -var _ = Describe("Encode", func() { - It("matches every golden vector", func() { - for name, want := range loadGoldens() { - if slices.Contains(headerOnlyFixtures, name) { - continue // see the dedicated header-only spec below - } - got, err := thumbhash.Encode(fixtureImage(name)) - Expect(err).ToNot(HaveOccurred(), "fixture %s", name) - Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want), "fixture %s", name) - } - }) - - It("reproduces the well-conditioned header of the ill-conditioned fixtures", func() { - for _, name := range headerOnlyFixtures { - want, err := base64.StdEncoding.DecodeString(loadGoldens()[name]) - Expect(err).ToNot(HaveOccurred(), "fixture %s", name) - got, err := thumbhash.Encode(fixtureImage(name)) - Expect(err).ToNot(HaveOccurred(), "fixture %s", name) - Expect(got[:5]).To(Equal(want[:5]), "fixture %s header bytes", name) - } - }) - - // The PNG fixtures cannot reach every layout; these sweep random sizes, aspects and both the - // 7x7 no-alpha and 5x5-plus-alpha coefficient regions against the same reference. - It("matches the reference on 300 generated images", func() { - vectors := loadJSON[[]struct { - W, H int - Hash string - }]("generated.json") - Expect(vectors).ToNot(BeEmpty()) - for i, want := range vectors { - img := generatedImage(i) - Expect(img.Bounds().Dx()).To(Equal(want.W), "vector %d width", i) - Expect(img.Bounds().Dy()).To(Equal(want.H), "vector %d height", i) - got, err := thumbhash.Encode(img) - Expect(err).ToNot(HaveOccurred(), "vector %d", i) - Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want.Hash), "vector %d (%dx%d)", i, want.W, want.H) - } - }) - - It("quantizes a uniform image's scales to zero", func() { - got, err := thumbhash.Encode(fixtureImage("solid.png")) - Expect(err).ToNot(HaveOccurred()) - header24 := int(got[0]) | int(got[1])<<8 | int(got[2])<<16 - header16 := int(got[3]) | int(got[4])<<8 - Expect((header24>>18)&31).To(Equal(0), "lScale") - Expect((header16>>3)&63).To(Equal(0), "pScale") - Expect((header16>>9)&63).To(Equal(0), "qScale") - }) - - It("produces 24 bytes for a square opaque image", func() { - got, err := thumbhash.Encode(fixtureImage("square.png")) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(24)) - }) - - It("produces 25 bytes when the image has alpha", func() { - got, err := thumbhash.Encode(fixtureImage("alpha.png")) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(25)) - }) - - It("downscales an oversized image rather than failing", func() { - img := image.NewNRGBA(image.Rect(0, 0, 500, 300)) - for i := range img.Pix { - img.Pix[i] = byte(i) - } - got, err := thumbhash.Encode(img) - Expect(err).ToNot(HaveOccurred()) - Expect(len(got)).To(BeNumerically(">=", 5)) - }) - - It("encodes a 1x1 image", func() { - img := image.NewNRGBA(image.Rect(0, 0, 1, 1)) - img.Set(0, 0, color.NRGBA{R: 60, G: 120, B: 180, A: 255}) - got, err := thumbhash.Encode(img) - Expect(err).ToNot(HaveOccurred()) - Expect(got).ToNot(BeEmpty()) - }) - - It("encodes a sub-image like an origin-anchored copy of the same region", func() { - parent := image.NewNRGBA(image.Rect(0, 0, 60, 50)) - for i := range parent.Pix { - parent.Pix[i] = byte(i * 7 % 251) - } - region := image.Rect(10, 7, 40, 30) - cropped := image.NewNRGBA(image.Rect(0, 0, region.Dx(), region.Dy())) - draw.Draw(cropped, cropped.Bounds(), parent, region.Min, draw.Src) - - got, err := thumbhash.Encode(parent.SubImage(region)) - Expect(err).ToNot(HaveOccurred()) - want, err := thumbhash.Encode(cropped) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(Equal(want)) - }) - - It("rejects an empty image", func() { - _, err := thumbhash.Encode(image.NewRGBA(image.Rect(0, 0, 0, 0))) - Expect(err).To(HaveOccurred()) - }) - - It("is deterministic", func() { - img := fixtureImage("square.png") - first, err := thumbhash.Encode(img) - Expect(err).ToNot(HaveOccurred()) - second, err := thumbhash.Encode(img) - Expect(err).ToNot(HaveOccurred()) - Expect(first).To(Equal(second)) - }) -}) diff --git a/core/artwork/trace.go b/core/artwork/trace.go deleted file mode 100644 index bca2f2c7d..000000000 --- a/core/artwork/trace.go +++ /dev/null @@ -1,152 +0,0 @@ -package artwork - -import ( - "context" - "encoding/json" - "io" - "slices" - "sync" - - "github.com/navidrome/navidrome/utils/str" -) - -// Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these. -type Outcome string - -const ( - OutcomeHit Outcome = "hit" - OutcomeMiss Outcome = "miss" - OutcomeUnreadable Outcome = "unreadable" - OutcomeSkipped Outcome = "skipped" - OutcomeError Outcome = "error" -) - -const ( - // externalCandidate labels the external tier itself, for the cases that never reach an agent. - externalCandidate = "external" - // ExternalPrefix qualifies a candidate or a stored source with the agent that produced it. - ExternalPrefix = externalCandidate + ":" -) - -// TraceStep is one candidate the priority chain considered. -type TraceStep struct { - Candidate string - Outcome Outcome - Detail string -} - -// ChainTrace collects the walk of a single resolution: the worker attaches one per queue -// item so it can be stored, and the CLI attaches one per explain. -type ChainTrace struct { - mu sync.Mutex - steps []TraceStep -} - -func (t *ChainTrace) add(step TraceStep) { - if t == nil { - return - } - t.mu.Lock() - defer t.mu.Unlock() - t.steps = append(t.steps, step) -} - -func (t *ChainTrace) Steps() []TraceStep { - if t == nil { - return nil - } - t.mu.Lock() - defer t.mu.Unlock() - return slices.Clone(t.steps) -} - -// maxTraceDetail bounds a stored Detail, which on the failure paths is an error string of -// unknown length. Past ~1kB a row spills to an overflow page, slowing every scan of the table. -const maxTraceDetail = 200 - -// storedStep is the persisted shape of a TraceStep. The keys are single letters because a trace -// is written for every item, and the encoded length is repeated across the whole library. -type storedStep struct { - C string `json:"c"` - O Outcome `json:"o"` - D string `json:"d,omitempty"` -} - -// encode serializes the trace for storage, without the copy Steps would make for a caller -// that only wants to write it. -func (t *ChainTrace) encode(sourcePath string) string { - if t == nil { - return encodeSteps(nil, sourcePath) - } - t.mu.Lock() - defer t.mu.Unlock() - return encodeSteps(t.steps, sourcePath) -} - -// encodeSteps writes the stored form. A hit's Detail is the winning source's path, which the -// same row already stores as source_path, so it is dropped and DecodeTrace puts it back. -func encodeSteps(steps []TraceStep, sourcePath string) string { - out := make([]storedStep, 0, len(steps)) - for _, s := range steps { - d := s.Detail - if s.Outcome == OutcomeHit && d == sourcePath { - d = "" - } - out = append(out, storedStep{C: s.Candidate, O: s.Outcome, D: str.TruncateRunes(d, maxTraceDetail, "...")}) - } - b, _ := json.Marshal(out) // []storedStep is all strings, so this cannot fail - return string(b) -} - -// DecodeTrace reverses the stored form. A trace that will not parse is reported as no trace at all, -// since a diagnostic command must not fail on a bad row. -func DecodeTrace(encoded, sourcePath string) []TraceStep { - if encoded == "" { - return nil - } - var stored []storedStep - if err := json.Unmarshal([]byte(encoded), &stored); err != nil { - return nil - } - steps := make([]TraceStep, 0, len(stored)) - for _, s := range stored { - d := s.D - if d == "" && s.O == OutcomeHit { - d = sourcePath - } - steps = append(steps, TraceStep{Candidate: s.C, Outcome: s.O, Detail: d}) - } - return steps -} - -type traceCtxKey struct{} - -func withTrace(ctx context.Context, t *ChainTrace) context.Context { - return context.WithValue(ctx, traceCtxKey{}, t) -} - -func traceFrom(ctx context.Context) *ChainTrace { - t, _ := ctx.Value(traceCtxKey{}).(*ChainTrace) - return t -} - -// recordAgent files what one external agent answered. The agent loops call this rather than a -// gate wrapper, because only they hold the context that carries the trace. -func recordAgent(ctx context.Context, name string, r io.ReadCloser, path string, err error) { - t := traceFrom(ctx) - candidate := ExternalPrefix + name - switch { - case r != nil: - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path}) - case isTransientExternal(err): - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()}) - default: - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss}) - } -} - -// traceStage records a failure from the stages that run after the priority chain has already -// picked a winner: most ways an item can fail are here, not in the chain walk. -func traceStage(ctx context.Context, stage string, err error) { - traceFrom(ctx).add(TraceStep{Candidate: stage, Outcome: OutcomeError, Detail: err.Error()}) -} diff --git a/core/artwork/trace_test.go b/core/artwork/trace_test.go deleted file mode 100644 index a16347457..000000000 --- a/core/artwork/trace_test.go +++ /dev/null @@ -1,664 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io" - "os" - "path/filepath" - "runtime" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "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" -) - -var _ = Describe("trace vocabulary", func() { - // The CLI renders these verbatim and branches on them; a value change is a change to - // what `artwork explain` tells an operator, so it must be made deliberately. - It("pins the wire values the CLI reads", func() { - Expect([]Outcome{ - OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeError, - }).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "error"})) - Expect(externalCandidate).To(Equal("external")) - Expect(ExternalPrefix).To(Equal("external:")) - }) -}) - -var _ = Describe("encodeSteps/DecodeTrace", func() { - It("round-trips a trace", func() { - steps := []TraceStep{ - {Candidate: "cover.png", Outcome: OutcomeMiss}, - {Candidate: "cover.*", Outcome: OutcomeHit, Detail: "/music/a/cover.jpg"}, - } - Expect(DecodeTrace(encodeSteps(steps, ""), "")).To(Equal(steps)) - }) - - It("encodes an empty trace as an empty JSON array", func() { - Expect(encodeSteps(nil, "")).To(Equal("[]")) - Expect(DecodeTrace("[]", "")).To(BeEmpty()) - }) - - It("tolerates a row written before the column existed", func() { - Expect(DecodeTrace("", "")).To(BeEmpty()) - }) - - // The hit detail repeats source_path byte for byte, and that column is on the same row. - It("drops a hit detail that repeats sourcePath, and restores it on read", func() { - path := "/music/artist/album/cover.jpg" - steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeHit, Detail: path}} - encoded := encodeSteps(steps, path) - Expect(encoded).NotTo(ContainSubstring(path)) - Expect(DecodeTrace(encoded, path)).To(Equal(steps)) - }) - - It("keeps a detail that differs from sourcePath", func() { - steps := []TraceStep{{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "https://cdn/x.jpg"}} - Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) - }) - - // A row past ~1kB spills to an overflow page on these WITHOUT ROWID tables, which would - // slow every scan; Detail is an error string on the failure paths, so it needs a bound. - It("bounds a detail so one long error cannot inflate the row", func() { - steps := []TraceStep{{Candidate: "decode", Outcome: OutcomeError, Detail: strings.Repeat("x", 5000)}} - - got := DecodeTrace(encodeSteps(steps, ""), "") - - Expect(len(got[0].Detail)).To(BeNumerically("<=", 210)) - Expect(got[0].Detail).To(HaveSuffix("...")) - Expect(got[0].Candidate).To(Equal("decode"), "truncating the detail must not disturb the step") - }) - - It("only restores sourcePath onto a detail-less hit", func() { - steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}} - Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) - }) -}) - -var _ = Describe("chainTrace", func() { - It("returns nil when no trace is attached", func() { - Expect(traceFrom(context.Background())).To(BeNil()) - }) - - It("collects steps in order", func() { - t := &ChainTrace{} - ctx := withTrace(context.Background(), t) - - traceFrom(ctx).add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) - traceFrom(ctx).add(TraceStep{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}) - - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "cover.*", Outcome: OutcomeMiss}, - {Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}, - })) - - s := t.Steps() - s[0].Candidate = "mutated" - Expect(t.Steps()[0].Candidate).To(Equal("cover.*")) - }) - - It("does not panic when the trace is nil", func() { - var t *ChainTrace - Expect(func() { t.add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) }).ToNot(Panic()) - Expect(t.Steps()).To(BeEmpty(), "a nil trace collects nothing, so reading it must be as safe as writing it") - }) - - It("is safe to use concurrently", func() { - t := &ChainTrace{} - done := make(chan struct{}) - for range 10 { - go func() { - defer GinkgoRecover() - t.add(TraceStep{Candidate: "x", Outcome: OutcomeMiss}) - done <- struct{}{} - }() - } - for range 10 { - <-done - } - Expect(t.Steps()).To(HaveLen(10)) - }) -}) - -var _ = Describe("chainState tracing", func() { - It("records a miss when the candidate was absent", func() { - t := &ChainTrace{} - c := chainState{trace: t} - - _, ok := c.try("cover.*", resolution{}, false) - - Expect(ok).To(BeFalse()) - Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}})) - }) - - It("records unreadable when the candidate existed but could not be read", func() { - t := &ChainTrace{} - c := chainState{trace: t} - - _, ok := c.try("cover.*", resolution{localError: true}, false) - - Expect(ok).To(BeFalse()) - Expect(t.Steps()).To(HaveLen(1)) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable), - "a candidate that existed and failed to decode must be distinguishable from one that was absent") - }) - - It("records a hit with the backing path", func() { - t := &ChainTrace{} - c := chainState{trace: t} - - res, ok := c.try("embedded", resolution{reader: nil, source: "embedded", sourcePath: "/music/a.flac"}, true) - - Expect(ok).To(BeTrue()) - Expect(res.source).To(Equal("embedded")) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}, - })) - }) -}) - -var _ = Describe("external agent tracing", func() { - var ( - t *ChainTrace - ctx context.Context - body io.ReadCloser - ) - BeforeEach(func() { - t = &ChainTrace{} - ctx = withTrace(context.Background(), t) - body = io.NopCloser(strings.NewReader("x")) - }) - - It("records a hit with the image path", func() { - recordAgent(ctx, "deezer", body, "http://img", nil) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"}, - })) - }) - - It("records a miss for a not-found", func() { - recordAgent(ctx, "deezer", nil, "", agents.ErrNotFound) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss)) - }) - - It("records a miss for a model not-found", func() { - recordAgent(ctx, "deezer", nil, "", model.ErrNotFound) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss), - "both not-found flavours are definitive answers, not faults") - }) - - It("records an error with its reason", func() { - recordAgent(ctx, "apple-music", nil, "", errors.New("returned status 429")) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError)) - Expect(t.Steps()[0].Detail).To(ContainSubstring("429")) - }) -}) - -var _ = Describe("resolveAlbum tracing", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - albumRepo *tests.MockAlbumRepo - folderRepo *fakeFolderRepo - ffm *tests.MockFFmpeg - ag *agents.Agents - t *ChainTrace - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.CoverArtPriority = "cover.jpg, embedded" - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - albumRepo = tests.CreateMockAlbumRepo() - folderRepo = &fakeFolderRepo{} - ds = &tests.MockDataStore{ - MockedAlbum: albumRepo, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - ffm = tests.NewMockFFmpeg("") - ag = agents.GetAgents(&tests.MockDataStore{}, nil) - t = &ChainTrace{} - ctx = withTrace(context.Background(), t) - }) - - It("records a pattern skipped because the album folder holds no images", func() { - albumRepo.SetData(model.Albums{ - {ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(t.Steps()).To(HaveLen(2), "a configured pattern must appear even when the chain never evaluated it") - Expect(t.Steps()[0]).To(Equal(TraceStep{ - Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder", - })) - Expect(t.Steps()[1].Candidate).To(Equal("embedded")) - Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) - }) - - It("records an evaluated pattern that matched nothing as a miss, not a skip", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, - }} - albumRepo.SetData(model.Albums{ - {ID: "al3", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "cover.jpg", Outcome: OutcomeMiss}), - "the folder was searched and held no cover.jpg, which is not the same as never looking") - }) - - It("ignores an empty priority token", func() { - conf.Server.CoverArtPriority = "cover.jpg," - albumRepo.SetData(model.Albums{{ID: "al2", Name: "Album", FolderIDs: []string{"f1"}}}) - - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"}, - })) - }) -}) - -var _ = Describe("resolveArtist tracing", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - artistRepo *tests.MockArtistRepo - albumRepo *tests.MockAlbumRepo - folderRepo *fakeFolderRepo - ffm *tests.MockFFmpeg - ag *agents.Agents - t *ChainTrace - repoRoot string - ) - - uploadPath := func(file string) string { - path := model.UploadedImagePath(consts.EntityArtist, file) - Expect(os.MkdirAll(filepath.Dir(path), 0o755)).To(Succeed()) - Expect(os.WriteFile(path, []byte("uploaded artist image"), 0o600)).To(Succeed()) - return path - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) - conf.Server.ArtistArtPriority = "album/artist.*" - var err error - repoRoot, err = os.Getwd() - Expect(err).ToNot(HaveOccurred()) - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - artistRepo = tests.CreateMockArtistRepo() - albumRepo = tests.CreateMockAlbumRepo() - folderRepo = &fakeFolderRepo{} - ds = &tests.MockDataStore{ - MockedArtist: artistRepo, - MockedAlbum: albumRepo, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - ffm = tests.NewMockFFmpeg("") - ag = agents.GetAgents(&tests.MockDataStore{}, nil) - t = &ChainTrace{} - ctx = withTrace(context.Background(), t) - }) - - It("records the upload short-circuit as a hit", func() { - path := uploadPath("ar1_test.jpg") - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}}) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "upload", Outcome: OutcomeHit, Detail: path}})) - }) - - It("records an upload miss before walking the chain", func() { - artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}}) - - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "upload", Outcome: OutcomeMiss})) - }) - - It("labels each step with the configured priority token", func() { - folderRepo.result = []model.Folder{{ - LibraryPath: testFileLibPath(repoRoot), - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, - }} - artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) - albumRepo.All = model.Albums{{ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}} - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(t.Steps()).To(HaveLen(2)) - Expect(t.Steps()[1].Candidate).To(Equal("album/artist.*"), - "the step must be labelled with the priority token, not the pattern it was rewritten into") - Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) - Expect(filepath.ToSlash(t.Steps()[1].Detail)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) - }) - - It("records a configured pattern that could not be evaluated", func() { - artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) - - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "upload", Outcome: OutcomeMiss}, - {Candidate: "album/artist.*", Outcome: OutcomeSkipped, Detail: "artist has no albums"}, - }), "a configured pattern that was never evaluated must still appear, and say why") - }) - - It("records why an artist folder pattern was skipped", func() { - conf.Server.ArtistArtPriority = "artist.*" - artistRepo.SetData(model.Artists{{ID: "ar6", Name: "Artist"}}) - albumRepo.All = model.Albums{{ID: "al10", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}} - - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar6"}) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "upload", Outcome: OutcomeMiss}, - {Candidate: "artist.*", Outcome: OutcomeSkipped, Detail: "no artist folder"}, - })) - }) - - It("records an upload that exists but cannot be read as unreadable", func() { - if runtime.GOOS == "windows" { - Skip("chmod does not restrict read access on Windows") - } - path := uploadPath("ar3_test.jpg") - Expect(os.Chmod(path, 0o000)).To(Succeed()) - DeferCleanup(func() { _ = os.Chmod(path, 0o600) }) - artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist", UploadedImage: "ar3_test.jpg"}}) - - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(HaveLen(1)) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable), - "an upload that exists and will not open must not look like an absent upload") - }) -}) - -var _ = Describe("NewTracingResolver", func() { - var ( - ds *tests.MockDataStore - albumRepo *tests.MockAlbumRepo - artistRepo *tests.MockArtistRepo - artworkRepo *tests.MockArtworkRepo - queueRepo *tests.MockArtworkQueueRepo - ffm *tests.MockFFmpeg - t *ChainTrace - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) - conf.Server.CoverArtPriority = "external, embedded" - conf.Server.ArtistArtPriority = "external" - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - albumRepo = tests.CreateMockAlbumRepo() - artistRepo = tests.CreateMockArtistRepo() - artworkRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - ds = &tests.MockDataStore{ - MockedAlbum: albumRepo, - MockedArtist: artistRepo, - MockedFolder: &fakeFolderRepo{}, - MockedLibrary: libRepo, - MockedArtwork: artworkRepo, - MockedArtworkQueue: queueRepo, - } - ffm = tests.NewMockFFmpeg("") - t = &ChainTrace{} - }) - - Context("resolving", func() { - var fake *fakeImageAgent - - BeforeEach(func() { - // Misses, so the chain falls through to the local tier and both are traced. - fake = &fakeImageAgent{name: "probe", err: agents.ErrNotFound} - albumRepo.SetData(model.Albums{{ - ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}, - }}) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) - }) - - It("asks the agents and records what each answered", func() { - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") - - Expect(err).ToNot(HaveOccurred()) - Expect(source).To(Equal("embedded")) - Expect(fake.albumCalls).To(Equal(1)) - Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:probe", Outcome: OutcomeMiss})) - }) - - It("records the local chain steps too", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") - - Expect(err).ToNot(HaveOccurred()) - last := t.Steps()[len(t.Steps())-1] - Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external tier") - Expect(last.Outcome).To(Equal(OutcomeHit)) - }) - - It("never persists artwork state", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") - - Expect(err).ToNot(HaveOccurred()) - Expect(artworkRepo.ItemData).To(BeEmpty(), - "explain is read-only; a diagnostic walk must never become the stored answer") - Expect(queueRepo.Data).To(BeEmpty()) - }) - - It("resolves an artist without persisting anything", func() { - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindArtistArtwork, "ar1") - - Expect(err).ToNot(HaveOccurred()) - Expect(source).To(BeEmpty()) - Expect(fake.artistCalls).To(Equal(1)) - Expect(artworkRepo.ItemData).To(BeEmpty()) - Expect(queueRepo.Data).To(BeEmpty()) - }) - - It("closes the reader it does not hand back", func() { - conf.Server.CoverArtPriority = "embedded" - ffm = tests.NewMockFFmpeg("fake image bytes") - albumRepo.SetData(model.Albums{{ - ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"}, - }}) - - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al2") - - Expect(err).ToNot(HaveOccurred()) - Expect(source).To(Equal("embedded")) - Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation") - }) - - // Serving falls back disc -> album and track -> disc -> album. The resolver does not, but - // if it ever did, an explain without --live would start calling providers uninvited. - It("cannot reach a provider without live, whatever the chain does", func() { - conf.Server.DiscArtPriority = "external, cover.*" - conf.Server.CoverArtPriority = "external, cover.*" - conf.Server.EnableMediaFileCoverArt = true - mfRepo := tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{{ID: "mf1", LibraryID: 0, HasCoverArt: true, - Path: "tests/fixtures/artist/an-album/test.mp3"}}) - ds.MockedMediaFile = mfRepo - offline := NewTracingResolver(ds, imageAgents(fake), ffm, t, false) - - _, err := offline.Resolve(context.Background(), model.KindDiscArtwork, "al1:1") - Expect(err).ToNot(HaveOccurred()) - _, err = offline.Resolve(context.Background(), model.KindMediaFileArtwork, "mf1") - Expect(err).ToNot(HaveOccurred()) - - Expect(fake.albumCalls).To(BeZero()) - Expect(fake.artistCalls).To(BeZero()) - }) - - It("propagates a lookup error", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "nope") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) -}) - -var _ = Describe("resolveDisc tracing", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - albumRepo *tests.MockAlbumRepo - folderRepo *fakeFolderRepo - ffm *tests.MockFFmpeg - t *ChainTrace - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - albumRepo = tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}}) - folderRepo = &fakeFolderRepo{} - mfRepo := tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{{ID: "mf1", AlbumID: "al1", DiscNumber: 2, Path: "tests/fixtures/artist/an-album/test.mp3"}}) - ds = &tests.MockDataStore{ - MockedAlbum: albumRepo, - MockedMediaFile: mfRepo, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - ffm = tests.NewMockFFmpeg("") - t = &ChainTrace{} - ctx = withTrace(context.Background(), t) - }) - - It("accounts for every configured entry, including the ones that map to no source", func() { - conf.Server.DiscArtPriority = "external, discsubtitle, cover.jpg" - - res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2)) - - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "external", Outcome: OutcomeSkipped, Detail: "external sources are not supported for disc artwork"}, - {Candidate: "discsubtitle", Outcome: OutcomeSkipped, Detail: "disc has no subtitle"}, - {Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"}, - })) - }) - - It("records the entry that won and stops there", func() { - conf.Server.DiscArtPriority = "disc*.*, cover.jpg, embedded" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - - res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2)) - - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(t.Steps()).To(HaveLen(2), "the walk must stop at the winner, and record nothing below it") - Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "disc*.*", Outcome: OutcomeMiss})) - Expect(t.Steps()[1].Candidate).To(Equal("cover.jpg")) - Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) - Expect(t.Steps()[1].Detail).To(HaveSuffix(filepath.FromSlash("tests/fixtures/artist/an-album/cover.jpg"))) - Expect(t.Steps()[1].Detail).ToNot(Equal("tests/fixtures/artist/an-album/cover.jpg"), - "a library-relative path sends the operator looking in the wrong place") - }) - - It("reports an unparseable disc id rather than explaining another disc", func() { - conf.Server.DiscArtPriority = "cover.jpg" - _, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, "al1") - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("resolveMediaFile tracing", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - mfRepo *tests.MockMediaFileRepo - ffm *tests.MockFFmpeg - t *ChainTrace - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableMediaFileCoverArt = true - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - mfRepo = tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{{ - ID: "mf1", Title: "Song", HasCoverArt: true, Path: "tests/fixtures/artist/an-album/test.mp3", - }}) - ds = &tests.MockDataStore{MockedMediaFile: mfRepo, MockedLibrary: libRepo} - ffm = tests.NewMockFFmpeg("") - t = &ChainTrace{} - ctx = withTrace(context.Background(), t) - }) - - It("separates a disabled setting from a track with nothing embedded", func() { - conf.Server.EnableMediaFileCoverArt = false - - _, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1") - - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "embedded", Outcome: OutcomeSkipped, Detail: "EnableMediaFileCoverArt is off"}, - })) - }) - - It("records a track with no embedded art as a miss", func() { - mfRepo.SetData(model.MediaFiles{{ID: "mf2", Title: "Song", HasCoverArt: false}}) - - _, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf2") - - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "embedded", Outcome: OutcomeMiss, Detail: "the track has no embedded cover art"}, - })) - }) - - It("records the embedded hit", func() { - res, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1") - - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("embedded")) - Expect(t.Steps()).To(HaveLen(1)) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeHit)) - }) -}) diff --git a/core/artwork/uploader.go b/core/artwork/uploader.go deleted file mode 100644 index 76526af3c..000000000 --- a/core/artwork/uploader.go +++ /dev/null @@ -1,106 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/dustin/go-humanize" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" -) - -// MaxImageUploadSize returns the configured max upload size in bytes, or the built-in default. -func MaxImageUploadSize() int64 { - return parseSize(conf.Server.MaxImageUploadSize, consts.DefaultMaxImageUploadSize) -} - -func parseSize(value, fallback string) int64 { - if size, err := humanize.ParseBytes(value); err == nil && size > 0 { - return int64(size) - } - size, _ := humanize.ParseBytes(fallback) - return int64(size) -} - -// Uploader stores a user-uploaded entity image and invalidates that entity's artwork state. -type Uploader interface { - SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) - RemoveImage(ctx context.Context, path string) error - // EnqueueArtwork re-resolves the item's artwork. Call it AFTER persisting the new - // filename, or the worker resolves the old one. - EnqueueArtwork(ctx context.Context, entityType, entityID string) -} - -var uploadEntityKind = map[string]model.Kind{ - consts.EntityArtist: model.KindArtistArtwork, - consts.EntityPlaylist: model.KindPlaylistArtwork, - consts.EntityRadio: model.KindRadioArtwork, -} - -type uploader struct { - ds model.DataStore -} - -func NewUploader(ds model.DataStore) Uploader { - return &uploader{ds: ds} -} - -func (s *uploader) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { - filename := imageFilename(entityID, name, ext) - absPath := model.UploadedImagePath(entityType, filename) - - if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { - return "", fmt.Errorf("creating image directory: %w", err) - } - - if oldPath != "" { - if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Artwork: Failed to remove old image", "path", oldPath, err) - } - } - - f, err := os.Create(absPath) - if err != nil { - return "", fmt.Errorf("creating image file: %w", err) - } - defer f.Close() - - if _, err := io.Copy(f, reader); err != nil { - return "", fmt.Errorf("writing image file: %w", err) - } - return filename, nil -} - -func (s *uploader) EnqueueArtwork(ctx context.Context, entityType, id string) { - kind, ok := uploadEntityKind[entityType] - if !ok { - return - } - if err := Refresh(ctx, s.ds, kind, id); err != nil { - log.Warn(ctx, "Artwork: Could not refresh artwork after upload", "kind", kind, "id", id, err) - } -} - -func (s *uploader) RemoveImage(ctx context.Context, path string) error { - if path == "" { - return nil - } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("removing image %q: %w", path, err) - } - return nil -} - -func imageFilename(id, name, ext string) string { - clean := utils.CleanFileName(name) - if clean == "" { - return id + ext - } - return id + "_" + clean + ext -} diff --git a/core/artwork/uploader_test.go b/core/artwork/uploader_test.go deleted file mode 100644 index b1420c935..000000000 --- a/core/artwork/uploader_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package artwork - -import ( - "context" - "os" - "path/filepath" - "strings" - - "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/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Uploader", func() { - var ctx context.Context - var svc Uploader - var tmpDir string - var artRepo *tests.MockArtworkRepo - var queueRepo *tests.MockArtworkQueueRepo - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - ds := &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} - svc = NewUploader(ds) - }) - - Describe("SetImage", func() { - It("creates directory and saves image file", func() { - reader := strings.NewReader("fake image data") - filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg") - Expect(err).ToNot(HaveOccurred()) - Expect(filename).To(Equal("ar-1_pink_floyd.jpg")) - - absPath := filepath.Join(tmpDir, "artwork", "artist", "ar-1_pink_floyd.jpg") - data, err := os.ReadFile(absPath) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("fake image data")) - }) - - It("falls back to ID-only filename when name cleans to empty", func() { - reader := strings.NewReader("data") - filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png") - Expect(err).ToNot(HaveOccurred()) - Expect(filename).To(Equal("pl-1.png")) - }) - - It("removes old image when replacing", func() { - oldDir := filepath.Join(tmpDir, "artwork", "artist") - Expect(os.MkdirAll(oldDir, 0755)).To(Succeed()) - oldFile := filepath.Join(oldDir, "ar-1_old.png") - Expect(os.WriteFile(oldFile, []byte("old"), 0600)).To(Succeed()) - - reader := strings.NewReader("new image") - _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "New Name", oldFile, reader, ".jpg") - Expect(err).ToNot(HaveOccurred()) - Expect(oldFile).ToNot(BeAnExistingFile()) - - newPath := filepath.Join(oldDir, "ar-1_new_name.jpg") - Expect(newPath).To(BeAnExistingFile()) - }) - - It("ignores missing old file without error", func() { - reader := strings.NewReader("data") - _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg") - Expect(err).ToNot(HaveOccurred()) - }) - - It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() { - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", - })).To(Succeed()) - - _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", strings.NewReader("img"), ".jpg") - Expect(err).ToNot(HaveOccurred()) - - // SetImage only writes the file; the state row survives and nothing is queued until - // the caller has persisted the new filename and called EnqueueArtwork. - _, err = artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty()) - }) - }) - - Describe("EnqueueArtwork", func() { - It("clears artwork state and enqueues a Bump", func() { - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", - })).To(Succeed()) - - svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1") - - _, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - - queued, err := queueRepo.DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "ar"), - HaveField("ItemID", "ar-1"), - HaveField("Priority", model.ArtworkPriorityBump), - ))) - }) - - It("is a no-op for an unknown entity type", func() { - svc.EnqueueArtwork(context.Background(), "unknown", "x-1") - Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty()) - }) - }) - - Describe("RemoveImage", func() { - It("removes the file at the given path", func() { - dir := filepath.Join(tmpDir, "artwork", "artist") - Expect(os.MkdirAll(dir, 0755)).To(Succeed()) - path := filepath.Join(dir, "ar-1_test.jpg") - Expect(os.WriteFile(path, []byte("img"), 0600)).To(Succeed()) - - err := svc.RemoveImage(ctx, path) - Expect(err).ToNot(HaveOccurred()) - Expect(path).ToNot(BeAnExistingFile()) - }) - - It("succeeds when file does not exist", func() { - err := svc.RemoveImage(ctx, "/nonexistent/file.jpg") - Expect(err).ToNot(HaveOccurred()) - }) - - It("succeeds with empty path", func() { - err := svc.RemoveImage(ctx, "") - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) - -var _ = Describe("MaxImageUploadSize", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("returns the configured size when valid", func() { - conf.Server.MaxImageUploadSize = "20MB" - Expect(MaxImageUploadSize()).To(Equal(int64(20_000_000))) - }) - - It("returns the default size when config is empty", func() { - conf.Server.MaxImageUploadSize = "" - Expect(MaxImageUploadSize()).To(Equal(int64(10_000_000))) - }) - - It("returns the default size when config is invalid", func() { - conf.Server.MaxImageUploadSize = "not-a-size" - Expect(MaxImageUploadSize()).To(Equal(int64(10_000_000))) - }) - - It("parses raw byte values", func() { - conf.Server.MaxImageUploadSize = "52428800" - Expect(MaxImageUploadSize()).To(Equal(int64(52_428_800))) - }) -}) diff --git a/core/artwork/wire_providers.go b/core/artwork/wire_providers.go index 54c2ad397..63231b54a 100644 --- a/core/artwork/wire_providers.go +++ b/core/artwork/wire_providers.go @@ -7,7 +7,5 @@ import ( var Set = wire.NewSet( NewArtwork, GetImageCache, - NewWorker, - GetImageStore, - NewUploader, + NewCacheWarmer, ) diff --git a/core/artwork/worker.go b/core/artwork/worker.go deleted file mode 100644 index 4be99f92e..000000000 --- a/core/artwork/worker.go +++ /dev/null @@ -1,366 +0,0 @@ -package artwork - -import ( - "bytes" - "cmp" - "context" - "fmt" - "io" - "math" - "math/rand/v2" - "runtime/debug" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/utils/cache" -) - -const ( - workerPollInterval = 5 * time.Second - backoffBase = 5 * time.Second - // giveUpAfter bounds the retry budget from enqueue; past it the item settles and only an - // explicit reprocess retries it. - giveUpAfter = 12 * time.Hour -) - -// drainPool drains one class of work with its own slot budget, so a blocking kind cannot -// occupy slots another kind needs. -type drainPool struct { - name string - kinds []string - concurrency int -} - -// Worker drains the artwork queue: each external agent is rate-limited and circuit-broken -// independently, and pruneMu serializes prune against the store-write window. -type Worker struct { - proc *processor - cache cache.FileCache - ffmpeg ffmpeg.FFmpeg - broker events.Broker - pruneMu sync.RWMutex - pools []*drainPool - runCtx context.Context //nolint:containedctx // worker lifecycle ctx, set at Run - paused func() bool - - gatesMu sync.Mutex - gates map[string]*extGate -} - -func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker { - w := &Worker{ - proc: &processor{ds: ds, store: store}, - cache: imgCache, - ffmpeg: ffmpeg, - broker: broker, - pools: newDrainPools(), - runCtx: context.Background(), - paused: func() bool { return false }, - gates: map[string]*extGate{}, - } - w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate) - w.proc.pruneLock = w.pruneMu.RLocker() - return w -} - -// newDrainPools splits the drain by what bounds it: gate() holds a slot while waiting for its -// rate-limit permit, so a sleeping lookup would crowd out a cover sitting on disk. -func newDrainPools() []*drainPool { - budget := conf.MaxOpenConns() // floored at 4, so both remainders below stay positive - local := min(max(1, conf.Server.DevArtworkWorkerConcurrency), budget-1) - // More external slots than the rate allows would only sleep in the limiter. - external := min(max(2, 2*conf.Server.DevArtworkExternalMaxRPS), budget-local) - return []*drainPool{ - {name: "local", kinds: localDrainKinds, concurrency: local}, - {name: "external", kinds: externalDrainKinds, concurrency: external}, - } -} - -// Kind is a proxy for cost: an album that reaches an external agent still costs a local slot. -var ( - externalDrainKinds = []string{model.KindArtistArtwork.Prefix()} - localDrainKinds = []string{ - model.KindAlbumArtwork.Prefix(), - model.KindPlaylistArtwork.Prefix(), - model.KindRadioArtwork.Prefix(), - model.KindMediaFileArtwork.Prefix(), - } -) - -// PauseWhile holds off queue draining whenever paused reports true. Call it before Run. -func (w *Worker) PauseWhile(paused func() bool) { - w.paused = paused -} - -// Run blocks draining the queue until ctx is cancelled. -func (w *Worker) Run(ctx context.Context) error { - w.runCtx = ctx - var wg sync.WaitGroup - for _, p := range w.pools { - wg.Go(func() { w.runPool(ctx, p) }) - } - wg.Wait() - return nil -} - -func (w *Worker) runPool(ctx context.Context, p *drainPool) { - ticker := time.NewTicker(workerPollInterval) - defer ticker.Stop() - for { - n, err := w.drain(ctx, p.concurrency, p.kinds...) - if err != nil && ctx.Err() == nil { - log.Warn(ctx, "Artwork: Worker drain failed", "pool", p.name, err) - } - if ctx.Err() != nil { - return - } - if n > 0 { - continue - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} - -// RunPrune runs prune under the worker's write lock, so no acquisition can place -// a file while orphans are being reclaimed. This is the only sanctioned prune path. -func (w *Worker) RunPrune(ctx context.Context) error { - w.pruneMu.Lock() - defer w.pruneMu.Unlock() - return prune(ctx, w.proc.ds, w.proc.store) -} - -// ReconcileConfig records the artwork config fingerprint, or warns when it changed. -func (w *Worker) ReconcileConfig(ctx context.Context) error { - return ReconcileConfigFingerprint(ctx, w.proc.ds) -} - -// EnqueueMissingAll requeues entities with no artwork state row: the safety net for anything -// a scan never enqueued. -func (w *Worker) EnqueueMissingAll(ctx context.Context) error { - return enqueueMissingAll(ctx, w.proc.ds) -} - -func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) { - if w.paused() { - return 0, nil - } - // Dequeue well past the pool size so a slow external lookup never idles the other slots. - // DequeueBatch does not mark rows taken, so this is one query per pass, not per slot. - items, err := w.proc.ds.ArtworkQueue().DequeueBatch(ctx, max(16, 4*concurrency), kinds...) - if err != nil { - return 0, err - } - if len(items) == 0 { - return 0, nil - } - drainStart := time.Now() - // Private playlists need an admin identity; resolved per drain because the worker can - // start before any admin exists. - ctx = auth.WithAdminUser(ctx, w.proc.ds) - sem := make(chan struct{}, concurrency) - var wg sync.WaitGroup - var refreshMu sync.Mutex - var refresh []model.ArtworkQueueItem - for _, item := range items { - select { - case sem <- struct{}{}: - case <-ctx.Done(): - } - // select picks randomly when both cases are ready, so re-check to never dispatch after cancellation. - if ctx.Err() != nil { - wg.Wait() - return len(items), nil //nolint:nilerr // a cancelled drain is a clean stop, not an error - } - if w.paused() { - break - } - wg.Go(func() { - defer func() { <-sem }() - out, got := w.process(ctx, item) - // Absent counts as a visible change too: clients must drop a previously-served - // immutable cover. - if out == outcomeFound || out == outcomeFoundStale || out == outcomeAbsent { - refreshMu.Lock() - refresh = append(refresh, item) - refreshMu.Unlock() - } - // Post-outcome: the queue row is already settled, so warming the cache can't - // block or alter queue ops. - if got != nil { - w.precache(ctx, got) - } - }) - } - wg.Wait() - w.broadcastRefresh(ctx, refresh) - log.Debug(ctx, "Artwork: Drained a batch", "kinds", kinds, "items", len(items), - "refreshed", len(refresh), "concurrency", concurrency, "elapsed", time.Since(drainStart)) - return len(items), nil -} - -// artworkKindToResource maps a kind to its UI resource name; media_file maps to "song", so -// this can't derive from Kind.String(). -var artworkKindToResource = map[model.Kind]string{ - model.KindAlbumArtwork: "album", - model.KindArtistArtwork: "artist", - model.KindPlaylistArtwork: "playlist", - model.KindRadioArtwork: "radio", - model.KindMediaFileArtwork: "song", -} - -// broadcastRefresh emits one coalesced RefreshResource for the batch, so UIs re-fetch the -// affected records and pick up the new coverArt id. -func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueueItem) { - if len(found) == 0 { - return - } - event := &events.RefreshResource{} - byResource := map[string][]string{} - for _, it := range found { - kind, _ := model.ParseKind(it.ItemKind) - if res, ok := artworkKindToResource[kind]; ok { - byResource[res] = append(byResource[res], it.ItemID) - } - } - if len(byResource) == 0 { - return - } - for res, ids := range byResource { - event = event.With(res, ids...) - } - w.broker.SendBroadcastMessage(ctx, event) -} - -func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) { - item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) - trace := &ChainTrace{} - ctx = withTrace(ctx, trace) - out, got, retryIn := w.safeAcquire(ctx, item) - - queue := w.proc.ds.ArtworkQueue() - switch out { - case outcomeFound, outcomeAbsent: - // A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives - // here and the next drain re-resolves it. - if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { - log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) - } - case outcomeFoundStale, outcomeFailed: - retryAt := time.Now().Add(retryDelay(item.Attempts, retryIn)) - encoded := trace.encode("") - if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) { - // A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its - // fresh, immediate eligibility. - if err := queue.MarkFailedIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil { - log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err) - } - log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID, - "outcome", out, "attempts", item.Attempts+1, "retryIn", time.Until(retryAt), - "budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter))) - break - } - // Art already being served is kept: exhaustion means unreachable, not removed. - settled := "kept previous state" - if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { - writeAbsent(ctx, w.proc.ds.Artwork(), item) - settled = "recorded absent" - } - // The queue row is about to go, taking the only record of the failure with it. This write is - // unconditional (not CAS-guarded) — safe only because the drain resolves each item serially. - w.recordGiveUp(ctx, item, encoded) - log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID, - "outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled) - if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { - log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err) - } - } - return out, got -} - -// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an -// unrecovered panic would crash the server, and the still-queued row would crash it again on restart. -func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { - defer func() { - if r := recover(); r != nil { - log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID, - "imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack())) - traceStage(ctx, "panic", fmt.Errorf("%v", r)) - out, got, retryIn = outcomeFailed, nil, 0 - } - }() - return w.proc.acquire(ctx, item) -} - -// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item -// that never resolved has no row to update, and creating one would settle it absent. -func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { - kind, ok := model.ParseKind(item.ItemKind) - if !ok { - return - } - if err := w.proc.ds.Artwork().PutLastFailure(ctx, kind, item.ItemID, item.ImageType, trace); err != nil { - log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err) - } -} - -func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool { - kind, ok := model.ParseKind(item.ItemKind) - if !ok { - return false - } - ia, err := w.proc.ds.Artwork().GetItemArtwork(ctx, kind, item.ItemID, item.ImageType) - return err == nil && ia.Hash != "" -} - -// precache warms the resize cache at the UI cover size from the bytes just acquired, so the -// first UI request hits without re-reading the rows or the file. -func (w *Worker) precache(ctx context.Context, got *acquired) { - if !conf.Server.EnableArtworkPrecache || w.cache == nil || w.cache.Disabled(ctx) { - return - } - precacheStart := time.Now() - // Same key as the serving path: square must match what the list surfaces request, or this - // warms a key nothing reads. - item := &resizedItem{ - hash: got.ia.Hash, - size: conf.Server.UICoverArtSize, - square: true, - ffmpeg: w.ffmpeg, - open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(got.data)), nil }, - } - stream, err := w.cache.Get(ctx, item) - if err != nil { - log.Debug(ctx, "Artwork: Precache failed", "kind", got.ia.ItemKind, "id", got.ia.ItemID, err) - return - } - _, _ = io.Copy(io.Discard, stream) - _ = stream.Close() - log.Trace(ctx, "Artwork: Precached UI size", "kind", got.ia.ItemKind, "id", got.ia.ItemID, - "size", conf.Server.UICoverArtSize, "elapsed", time.Since(precacheStart)) -} - -// backoffFor returns min(5s×4^n, giveUpAfter) scaled by (1+jitter), with jitter in [-0.4, 0.4]. -func backoffFor(attempts int, jitter float64) time.Duration { - d := min(float64(backoffBase)*math.Pow(4, float64(attempts)), float64(giveUpAfter)) - return time.Duration(d * (1 + jitter)) -} - -func backoff(attempts int) time.Duration { - return backoffFor(attempts, rand.Float64()*0.8-0.4) //nolint:gosec // retry jitter, not security-sensitive -} - -// retryDelay is how long a failed item waits: our backoff, unless the provider asked for longer. -func retryDelay(attempts int, hint time.Duration) time.Duration { - return max(backoff(attempts), hint) -} diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go deleted file mode 100644 index 9be95cf2a..000000000 --- a/core/artwork/worker_soak_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package artwork - -import ( - "context" - "io" - "os" - "runtime" - "testing" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Deliberately >2000: a leak guard favors a stable signal over speed. -const soakCycles = 2200 - -var _ = Describe("Worker soak", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - It("does not leak goroutines, heap, or fds over many acquisition cycles", func() { - if testing.Short() { - Skip("skipping soak test in short mode") - } - DeferCleanup(configtest.SetupConfig()) - - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - folderRepo := &fakeFolderRepo{result: []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }}} - ffm := tests.NewMockFFmpeg("") - ag := agents.GetAgents(&tests.MockDataStore{}, nil) - artRepo := tests.CreateMockArtworkRepo() - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{ - {ID: "al-folder", Name: "Folder Album", FolderIDs: []string{"f1"}}, - {ID: "al-embed", Name: "Embedded Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - ds := &tests.MockDataStore{ - MockedFolder: folderRepo, - MockedLibrary: libRepo, - MockedArtwork: artRepo, - MockedAlbum: albumRepo, - } - store := NewImageStore(GinkgoT().TempDir()) - proc := &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)} - conf.Server.CoverArtPriority = "cover.jpg, embedded" - - // Dangling refs mirror an entity deleted after being enqueued. - items := []model.ArtworkQueueItem{ - {ItemKind: "al", ItemID: "al-folder"}, - {ItemKind: "al", ItemID: "al-embed"}, - {ItemKind: "al", ItemID: "al-does-not-exist"}, - {ItemKind: "ra", ItemID: "ra-does-not-exist"}, - } - - fdCount := func() int { - if runtime.GOOS != "linux" { - return -1 - } - entries, err := os.ReadDir("/proc/self/fd") - if err != nil { - return -1 - } - return len(entries) - } - - settleGoroutines := func() int { - // Background goroutines wind down late, so poll for two consecutive equal samples. - prev := -1 - for range 100 { - runtime.GC() - n := runtime.NumGoroutine() - if n == prev { - return n - } - prev = n - time.Sleep(10 * time.Millisecond) - } - return prev - } - - baselineGoroutines := settleGoroutines() - baselineFDs := fdCount() - - var heapAt10Pct uint64 - start := time.Now() - for i := range soakCycles { - it := items[i%len(items)] - out, _, _ := proc.acquire(context.Background(), it) - - // Read-back exercises the surfaces a caller would use after acquisition. - if out == outcomeFound { - kind, _ := model.ParseKind(it.ItemKind) - ia, err := artRepo.GetItemArtwork(ctx, kind, it.ItemID, model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i) - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i) - rc, err := store.Open(ia.Hash, art.Mime) - switch { - case err == nil: - _, _ = io.Copy(io.Discard, rc) - rc.Close() - case os.IsNotExist(err): - // Folder-backed art has no store file; that's expected. - default: - Expect(err).ToNot(HaveOccurred(), "cycle %d: store.Open", i) - } - } - - if i == soakCycles/10 { - runtime.GC() - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - heapAt10Pct = ms.HeapAlloc - } - } - elapsed := time.Since(start) - - finalGoroutines := settleGoroutines() - finalFDs := fdCount() - - runtime.GC() - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - - GinkgoWriter.Printf("soak: cycles=%d elapsed=%s goroutines(baseline=%d final=%d) heap(10%%-mark=%d final=%d) fds(baseline=%d final=%d)\n", - soakCycles, elapsed, baselineGoroutines, finalGoroutines, heapAt10Pct, ms.HeapAlloc, baselineFDs, finalFDs) - - Expect(finalGoroutines).To(BeNumerically("<=", baselineGoroutines), "goroutine count grew: baseline=%d final=%d", baselineGoroutines, finalGoroutines) - if heapAt10Pct > 0 { - Expect(ms.HeapAlloc).To(BeNumerically("<=", 2*heapAt10Pct), "heap did not plateau: 10%%-mark=%d final=%d (final > 2x 10%%-mark)", heapAt10Pct, ms.HeapAlloc) - } - if runtime.GOOS == "linux" && baselineFDs >= 0 { - Expect(finalFDs).To(BeNumerically("<=", baselineFDs), "fd count grew: baseline=%d final=%d", baselineFDs, finalFDs) - } - }) -}) diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go deleted file mode 100644 index 80ca68bc3..000000000 --- a/core/artwork/worker_test.go +++ /dev/null @@ -1,1073 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "os" - "slices" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.uber.org/goleak" -) - -type recordingCache struct { - cache.FileCache - mu sync.Mutex - keys []string - disabled bool -} - -func (c *recordingCache) Disabled(ctx context.Context) bool { - return c.disabled || c.FileCache.Disabled(ctx) -} - -func (c *recordingCache) Get(ctx context.Context, arg cache.Item) (*cache.CachedStream, error) { - c.mu.Lock() - c.keys = append(c.keys, arg.Key()) - c.mu.Unlock() - return c.FileCache.Get(ctx, arg) -} - -func (c *recordingCache) getKeys() []string { - c.mu.Lock() - defer c.mu.Unlock() - return slices.Clone(c.keys) -} - -// Simulates a concurrent Enqueue between DequeueBatch and the worker's delete, so -// DeleteIfUnchanged on the dequeued value no-ops. -type reenqueueOnDequeue struct { - *tests.MockArtworkQueueRepo - done bool -} - -func (r *reenqueueOnDequeue) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) { - items, err := r.MockArtworkQueueRepo.DequeueBatch(ctx, n, kinds...) - if !r.done && len(items) > 0 { - r.done = true - for k, it := range r.Data { - if it.ItemKind == items[0].ItemKind && it.ItemID == items[0].ItemID { - it.RetryAt = items[0].RetryAt.Add(time.Minute) - r.Data[k] = it - } - } - } - return items, err -} - -type fakeEventBroker struct { - http.Handler - mu sync.Mutex - events []events.Event -} - -func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { - f.mu.Lock() - defer f.mu.Unlock() - f.events = append(f.events, event) -} - -func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { - f.mu.Lock() - defer f.mu.Unlock() - f.events = append(f.events, event) -} - -func (f *fakeEventBroker) getEvents() []events.Event { - f.mu.Lock() - defer f.mu.Unlock() - return slices.Clone(f.events) -} - -var _ events.Broker = (*fakeEventBroker)(nil) - -// expireQueued ages a row past the retry budget, so the next drain settles it instead of retrying. -func expireQueued(q *tests.MockArtworkQueueRepo, id string) { - GinkgoHelper() - for k, v := range q.Data { - if v.ItemID == id { - v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) - q.Data[k] = v - } - } -} - -func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem { - for _, it := range q.Data { - if it.ItemKind == kind && it.ItemID == id { - return &it - } - } - return nil -} - -// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only -// visible when the ctx carries an admin, so headless work must wrap ctx with one first. -type visibilityPlaylistDS struct { - *tests.MockDataStore - private model.Playlist - tracks model.PlaylistTrackRepository -} - -func (v *visibilityPlaylistDS) Playlist() model.PlaylistRepository { - repo := tests.CreateMockPlaylistRepo() - repo.TracksRepo = v.tracks - repo.SetData(model.Playlists{v.private}) - return &visibilityPlaylistRepo{MockPlaylistRepo: repo} -} - -type visibilityPlaylistRepo struct { - *tests.MockPlaylistRepo -} - -func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Playlist, error) { - if u, ok := request.UserFrom(ctx); !ok || !u.IsAdmin { - return nil, model.ErrNotFound - } - return v.MockPlaylistRepo.Get(ctx, id) -} - -type panickingAlbumRepo struct { - *tests.MockAlbumRepo - panicID string -} - -func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) { - if id == r.panicID { - panic("boom") - } - return r.MockAlbumRepo.Get(ctx, id) -} - -func adminUserRepo() *tests.MockedUserRepo { - repo := tests.CreateMockUserRepo() - Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) - return repo -} - -var _ = Describe("Worker", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - folderRepo *fakeFolderRepo - libRepo *tests.MockLibraryRepo - ffm *tests.MockFFmpeg - ag *agents.Agents - store *ImageStore - artRepo *tests.MockArtworkRepo - queueRepo *tests.MockArtworkQueueRepo - broker *fakeEventBroker - imgCache *recordingCache - repoRoot string - w *Worker - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - var err error - repoRoot, err = os.Getwd() - Expect(err).ToNot(HaveOccurred()) - conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) - - folderRepo = &fakeFolderRepo{} - libRepo = &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - ffm = tests.NewMockFFmpeg("") - ag = agents.GetAgents(&tests.MockDataStore{}, nil) - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - ds = &tests.MockDataStore{ - MockedFolder: folderRepo, - MockedLibrary: libRepo, - MockedArtwork: artRepo, - MockedArtworkQueue: queueRepo, - } - ds.MockedAlbum = tests.CreateMockAlbumRepo() - store = NewImageStore(GinkgoT().TempDir()) - conf.Server.CoverArtPriority = "cover.jpg, embedded" - conf.Server.DevArtworkExternalMaxRPS = 1000 // keep the limiter out of the way of behavior tests - broker = &fakeEventBroker{} - imgCache = &recordingCache{FileCache: cache.NewFileCache("WorkerTest", "100MB", "images", 0, - func(ctx context.Context, arg cache.Item) (io.Reader, error) { - return arg.(artworkReader).Reader(ctx) - })} - // Init walks the cache dir on a goroutine; loaded CI runners can take >1s. - Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - }) - - Describe("drain", func() { - It("processes a seeded queue item and removes it from the queue", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, - }) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - - count, err := queueRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero(), "a found item must be deleted from the queue") - }) - - It("processes an mf queue item, writing state and storing embedded bytes", func() { - conf.Server.EnableMediaFileCoverArt = true - ds.MockedMediaFile = tests.CreateMockMediaFileRepo() - ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, - }) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump, - })).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("embedded")) - Expect(ia.Hash).ToNot(BeEmpty()) - - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - r, err := store.Open(ia.Hash, art.Mime) - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store") - - count, err := queueRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero()) - }) - - It("reschedules a failed item via MarkFailed with a backed-off retry_at", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - it := findQueued(queueRepo, "al", "al4") - Expect(it).ToNot(BeNil()) - Expect(it.Attempts).To(Equal(1)) - Expect(it.RetryAt).To(BeTemporally(">", time.Now())) - - _, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") - }) - - It("fails an item that panics, without stopping the rest of the batch", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - albums := tests.CreateMockAlbumRepo() - albums.SetData(model.Albums{ - {ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}}, - {ID: "alok", Name: "Album", FolderIDs: []string{"f1"}}, - }) - ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"} - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed()) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed()) - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(2)) - - it := findQueued(queueRepo, "al", "alboom") - Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped") - Expect(it.Attempts).To(Equal(1)) - Expect(it.RetryAt).To(BeTemporally(">", time.Now())) - Expect(it.Trace).To(ContainSubstring("boom")) - - Expect(findQueued(queueRepo, "al", "alok")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - }) - - It("reschedules past the provider's requested delay when it exceeds the backoff", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) - // Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at. - const askedFor = 90 * time.Minute - imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - it := findQueued(queueRepo, "al", "al9") - Expect(it).ToNot(BeNil()) - Expect(it.RetryAt).To(BeTemporally("~", time.Now().Add(askedFor), time.Minute)) - }) - - It("reschedules a found-stale item via MarkFailed while keeping its served state", func() { - conf.Server.CoverArtPriority = "external, cover.jpg" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - it := findQueued(queueRepo, "al", "alstale") - Expect(it).ToNot(BeNil(), "a found-stale row must survive for a higher-priority retry") - Expect(it.Attempts).To(Equal(1)) - Expect(it.RetryAt).To(BeTemporally(">", time.Now())) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile") - - evts := broker.getEvents() - Expect(evts).To(HaveLen(1), "the served fallback art must live-refresh the UI") - Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("alstale")) - }) - - It("keeps a row re-enqueued between dequeue and delete", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al7", Name: "Album", FolderIDs: []string{"f1"}}, - }) - racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} - ds.MockedArtworkQueue = racing - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - // The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op. - Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - }) - - It("keeps a fresh re-enqueue ahead of a stale failure backoff", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al8", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} - ds.MockedArtworkQueue = racing - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed()) - dequeued := findQueued(queueRepo, "al", "al8").RetryAt - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - // The re-enqueue reset retry_at; the failure path must not stomp it nor bump attempts. - it := findQueued(queueRepo, "al", "al8") - Expect(it).ToNot(BeNil()) - Expect(it.Attempts).To(BeZero()) - Expect(it.RetryAt).To(BeTemporally("==", dequeued.Add(time.Minute))) - }) - - It("gives up and settles absent once the retry budget is exhausted", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) - // Age the row past the retry budget. - expireQueued(queueRepo, "al9") - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(findQueued(queueRepo, "al", "al9")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al9", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(BeEmpty()) - }) - - It("keeps already-served art when the retry budget is exhausted", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}}) - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary, - Hash: "cafebabe", Source: "external:lastfm", - })).To(Succeed()) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed()) - expireQueued(queueRepo, "al10") - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(findQueued(queueRepo, "al", "al10")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al10", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art") - }) - - It("records on the queue row why the last attempt failed", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed()) - - _, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - - it := findQueued(queueRepo, "al", "al11") - Expect(it).ToNot(BeNil()) - Expect(DecodeTrace(it.Trace, "")).To(ContainElement(SatisfyAll( - HaveField("Candidate", "external:failAgent"), - HaveField("Outcome", OutcomeError), - HaveField("Detail", ContainSubstring("agent timed out")), - )), "a retrying row must say why it is retrying") - }) - - // The give-up path settles absent before recording, so the row exists by the time the - // failure is written. Recording first would silently lose it for every unresolved item. - It("keeps the failure for an item that never resolved at all", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed()) - expireQueued(queueRepo, "al13") - - _, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al13", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to") - Expect(ia.Hash).To(BeEmpty()) - Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty()) - }) - - It("keeps the failure on the state row after the queue row is deleted", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}}) - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary, - Hash: "cafebabe", Source: "external:lastfm", - })).To(Succeed()) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed()) - expireQueued(queueRepo, "al12") - - _, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - - Expect(findQueued(queueRepo, "al", "al12")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al12", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(), - "the queue row is gone, so this is the only remaining record of the failure") - Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the served art") - }) - - // Only a view enqueues a media file, and an absent row is exactly what stops a view from - // doing so: a transient read error would look permanent. - It("does not settle absent on exhaustion for a media file", func() { - conf.Server.EnableMediaFileCoverArt = true - ds.MockedMediaFile = tests.CreateMockMediaFileRepo() - ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true}, - }) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed()) - expireQueued(queueRepo, "mfX") - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying") - _, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), - "no row leaves the track unresolved, so a later view can still recover it") - // Known gap: with no row and no absent settle, there is nowhere to keep the failure. - // Creating one here would write an empty hash, which every reader treats as absent. - }) - - It("resolves a private playlist under an admin context instead of failing forever", func() { - ds.MockedUser = adminUserRepo() - vds := &visibilityPlaylistDS{ - MockDataStore: ds, - private: model.Playlist{ID: "plPriv", OwnerID: "admin"}, - tracks: &tests.MockPlaylistTrackRepo{}, - } - w = NewWorker(vds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed()) - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(BeEmpty()) - }) - - It("returns zero when the queue is empty", func() { - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(BeZero()) - }) - - It("broadcasts a single refresh event for the found items in a batch", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}}, - {ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}}, - }) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed()) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed()) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed()) - - n, err := w.drain(ctx, 3) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(3)) - - evts := broker.getEvents() - Expect(evts).To(HaveLen(1), "exactly one coalesced event per drain batch") - rr, ok := evts[0].(*events.RefreshResource) - Expect(ok).To(BeTrue()) - data := rr.Data(rr) - Expect(data).To(ContainSubstring(`"album"`)) - Expect(data).To(ContainSubstring("al1")) - Expect(data).To(ContainSubstring("al2")) - Expect(data).ToNot(ContainSubstring("artist"), "a failed (unresolved) artist must not be refreshed") - Expect(data).ToNot(ContainSubstring("ar1")) - }) - - DescribeTable("only lists the kinds it actually resolved", - func(kinds []string, wantSong bool) { - items := slice.Map(kinds, func(k string) model.ArtworkQueueItem { - return model.ArtworkQueueItem{ItemKind: k, ItemID: k + "1"} - }) - w.broadcastRefresh(ctx, items) - - evts := broker.getEvents() - Expect(evts).To(HaveLen(1)) - data := evts[0].(*events.RefreshResource).Data(evts[0]) - if wantSong { - Expect(data).To(ContainSubstring(`"song"`)) - } else { - Expect(data).ToNot(ContainSubstring(`"song"`)) - } - }, - // An album's tracks inherit its art, but the dependent ids are unbounded: the client - // fans an album refresh out to the tracks it has loaded. - Entry("album alone does not name songs", []string{"al"}, false), - Entry("artist alone does not", []string{"ar"}, false), - Entry("playlist alone does not", []string{"pl"}, false), - Entry("album mixed with others still does not", []string{"ar", "al"}, false), - Entry("songs resolving on their own are listed by id", []string{"mf"}, true), - ) - - It("broadcasts a refresh when an item resolves to absent (removed cover)", func() { - conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}}) - folderRepo.result = nil - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - evts := broker.getEvents() - Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it") - Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found") - }) - - It("does not broadcast when no item is found", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(broker.getEvents()).To(BeEmpty(), "a drain with no found items sends no event") - }) - }) - - Describe("gate/breaker", func() { - It("opens after 5 consecutive external errors and short-circuits the step", func() { - var calls int - failing := func() (io.ReadCloser, string, error) { - calls++ - return nil, "", errors.New("boom") - } - for range 5 { - _, _, err := w.gate("A", failing) - Expect(err).To(HaveOccurred()) - } - Expect(calls).To(Equal(5)) - - _, _, err := w.gate("A", failing) - Expect(err).To(MatchError(errBreakerOpen)) - Expect(calls).To(Equal(5), "an open breaker must not call the external step") - }) - - It("resets the failure count on a successful call", func() { - failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } - ok := func() (io.ReadCloser, string, error) { return io.NopCloser(nil), "p", nil } - for range 4 { - _, _, _ = w.gate("A", failing) - } - _, _, err := w.gate("A", ok) - Expect(err).ToNot(HaveOccurred()) - - var calls int - counting := func() (io.ReadCloser, string, error) { - calls++ - return nil, "", errors.New("boom") - } - for range 5 { - _, _, _ = w.gate("A", counting) - } - Expect(calls).To(Equal(5), "the breaker should have re-closed after the success") - }) - - It("does not open the breaker when the run is cancelled", func() { - cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled } - for range breakerThreshold + 3 { - _, _, err := w.gate("A", cancelled) - Expect(err).To(MatchError(context.Canceled), "a cancellation passes through, never errBreakerOpen") - } - - var calls int - counting := func() (io.ReadCloser, string, error) { - calls++ - return nil, "", errors.New("boom") - } - _, _, _ = w.gate("A", counting) - Expect(calls).To(Equal(1), "the breaker stayed closed, so the step still runs") - }) - - It("ignores a cancellation mid-run, neither counting nor clearing the failures", func() { - failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } - cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled } - for range breakerThreshold - 1 { - _, _, _ = w.gate("A", failing) - } - _, _, _ = w.gate("A", cancelled) - - var calls int - counting := func() (io.ReadCloser, string, error) { - calls++ - return nil, "", errors.New("boom") - } - _, _, _ = w.gate("A", counting) - Expect(calls).To(Equal(1), "the cancellation must not have counted as the final failure") - - _, _, err := w.gate("A", counting) - Expect(err).To(MatchError(errBreakerOpen), "the cancellation must not have cleared the earlier failures") - Expect(calls).To(Equal(1), "an open breaker must not call the external step") - }) - - It("does not open the breaker on a run of agent not-found misses", func() { - // agents.ErrNotFound is a definitive miss, not a fault: artless items must not - // trip the breaker, or they would loop in retry instead of settling absent. - notFound := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound } - for range breakerThreshold + 3 { - _, _, err := w.gate("A", notFound) - Expect(err).To(MatchError(agents.ErrNotFound), "a miss passes through, never errBreakerOpen") - } - - var calls int - counting := func() (io.ReadCloser, string, error) { - calls++ - return nil, "", errors.New("boom") - } - _, _, _ = w.gate("A", counting) - Expect(calls).To(Equal(1), "the breaker stayed closed, so the step still runs") - }) - - It("isolates each agent's breaker: one open gate does not block another", func() { - failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } - for range breakerThreshold { - _, _, _ = w.gate("A", failing) - } - _, _, err := w.gate("A", failing) - Expect(err).To(MatchError(errBreakerOpen), "agent A's breaker is open") - - var bCalls int - bStep := func() (io.ReadCloser, string, error) { - bCalls++ - return io.NopCloser(nil), "p", nil - } - for range breakerThreshold + 2 { - _, _, err := w.gate("B", bStep) - Expect(err).ToNot(HaveOccurred(), "agent B keeps being called while A is open") - } - Expect(bCalls).To(Equal(breakerThreshold + 2)) - }) - }) - - Describe("precache", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}}, - }) - conf.Server.UICoverArtSize = 300 - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - }) - - It("warms the resize cache at the UI cover size after a found acquisition", func() { - conf.Server.EnableArtworkPrecache = true - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - // The list surfaces request square covers, so warming any other key is wasted work. - Expect(imgCache.getKeys()).To(ContainElement(ContainSubstring(".300.true."))) - }) - - It("skips warming when precache is disabled", func() { - conf.Server.EnableArtworkPrecache = false - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(imgCache.getKeys()).To(BeEmpty()) - }) - - // It warms from the bytes acquisition already held, so no state row or store file is needed. - It("warms from the acquired bytes without reading them back", func() { - conf.Server.EnableArtworkPrecache = true - ia := &model.ItemArtwork{ - ItemKind: "al", ItemID: "unpersisted", ImageType: model.ImageTypePrimary, - Hash: "abcdef0123456789", UpdatedAt: time.Now(), - } - - data, err := os.ReadFile("tests/fixtures/artist/an-album/cover.jpg") - Expect(err).ToNot(HaveOccurred()) - - w.precache(ctx, &acquired{ia: ia, mime: "image/jpeg", data: data}) - - // Nothing backs that hash on disk, so a hit can only come from the bytes handed in; - // the probe refuses to open, proving nothing is re-read. - probe := &resizedItem{ - hash: ia.Hash, size: 300, square: true, ffmpeg: ffm, - open: func() (io.ReadCloser, error) { return nil, errors.New("precache must not re-read the source") }, - } - stream, err := imgCache.Get(ctx, probe) - Expect(err).ToNot(HaveOccurred()) - defer stream.Close() - Expect(io.ReadAll(stream)).ToNot(BeEmpty()) - }) - }) - - Describe("drain pools", func() { - // A kind in neither pool is never dequeued, with nothing to catch it at compile time. - It("covers every kind the worker can process, exactly once", func() { - var pooled []string - for _, p := range newDrainPools() { - pooled = append(pooled, p.kinds...) - } - for kind := range artworkKindToResource { - Expect(pooled).To(ContainElement(kind.Prefix()), "kind %q belongs to no drain pool", kind.Prefix()) - } - Expect(pooled).To(HaveLen(len(artworkKindToResource)), "a kind is claimed by more than one pool") - }) - - // Artists resolve through a rate-limited agent that holds its slot while waiting, so one - // shared pool would park every album behind them. - It("resolves albums while artists are stuck on a slow agent", func() { - conf.Server.CoverArtPriority = "cover.jpg" - conf.Server.ArtistArtPriority = "external" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album", FolderIDs: []string{"f1"}}}) - ds.MockedArtist = tests.CreateMockArtistRepo() - - // Every artist lookup blocks until released, standing in for the rate limiter. - block := make(chan struct{}) - artists := model.Artists{} - for i := range 8 { - artists = append(artists, model.Artist{ID: fmt.Sprintf("arx%d", i), Name: "A"}) - } - ds.MockedArtist.(*tests.MockArtistRepo).SetData(artists) - imageAgents(&fakeImageAgent{name: "slowAgent", block: block}) - - // Artists first, exactly as Backfill orders them. - for _, a := range artists { - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill, - })).To(Succeed()) - } - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill, - })).To(Succeed()) - - runCtx, cancel := context.WithCancel(ctx) - done := make(chan struct{}) - go func() { defer close(done); _ = w.Run(runCtx) }() - // Join Run: a leaked pool goroutine would race the config snapshot Ginkgo restores. - DeferCleanup(func() { - cancel() - close(block) // unpark the blocked lookups so the pools can unwind - <-done - }) - - Eventually(func() bool { - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alx", model.ImageTypePrimary) - return err == nil && ia.Hash != "" - }, 5*time.Second, 50*time.Millisecond).Should(BeTrue(), - "a blocked external pool must not hold up local artwork") - - _, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "arx0", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended") - }) - }) - - Describe("batching", func() { - It("leaves undispatched items queued when cancelled mid-batch", func() { - // Every album must resolve, so any dispatched item deletes its row regardless of dequeue order. - albums := model.Albums{} - for i := range 8 { - id := fmt.Sprintf("alc%d", i) - albums = append(albums, model.Album{ID: id, Name: "Album"}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - } - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums) - cancelledCtx, cancel := context.WithCancel(ctx) - cancel() - - _, err := w.drain(cancelledCtx, 1) - Expect(err).ToNot(HaveOccurred()) - - for i := range 8 { - id := fmt.Sprintf("alc%d", i) - Expect(findQueued(queueRepo, "al", id)).ToNot(BeNil(), "row "+id+" must survive a cancelled drain") - } - }) - - It("stops dispatching and leaves the rest queued when paused mid-batch", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - albums := model.Albums{} - for i := range 8 { - id := fmt.Sprintf("alp%d", i) - albums = append(albums, model.Album{ID: id, Name: "Album", FolderIDs: []string{"f1"}}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - } - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums) - // Pauses as soon as the first item has left the queue. - w.PauseWhile(func() bool { - n, _ := queueRepo.Count(ctx) - return n < 8 - }) - - _, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - - count, err := queueRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue") - }) - - It("dequeues past the worker pool so one drain covers many items", func() { - for i := range 16 { - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - } - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(16), "a batch sized to the pool would have stopped at 4") - }) - }) - - Describe("RunPrune", func() { - It("runs a prune under the worker mutex", func() { - Expect(w.RunPrune(ctx)).To(Succeed()) - }) - }) - - Describe("Run", func() { - It("exits cleanly when the context is cancelled", func() { - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { done <- w.Run(runCtx) }() - - cancel() - Eventually(done, time.Second).Should(Receive(BeNil())) - }) - - It("does not drain the queue while paused", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, - }) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - w.PauseWhile(func() bool { return true }) - - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { done <- w.Run(runCtx) }() - DeferCleanup(func() { - cancel() - Eventually(done, 2*time.Second).Should(Receive(BeNil())) - }) - - Consistently(func() any { return findQueued(queueRepo, "al", "al1") }, 300*time.Millisecond).ShouldNot(BeNil()) - }) - - It("does not leak goroutines after Run exits", func() { - DeferCleanup(configtest.SetupConfig()) - - ignore := goleak.IgnoreCurrent() - DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) }) - - localDS := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()} - lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), agents.GetAgents(localDS, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, imgCache) - - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { done <- lw.Run(runCtx) }() - - time.Sleep(20 * time.Millisecond) // let the loop settle on the idle select - cancel() - Eventually(done, 2*time.Second).Should(Receive(BeNil())) - }) - }) -}) - -var _ = Describe("backoff", func() { - It("returns the expected schedule with no jitter", func() { - for _, c := range []struct { - attempts int - want time.Duration - }{ - {0, 5 * time.Second}, - {1, 20 * time.Second}, - {2, 80 * time.Second}, - {3, 320 * time.Second}, - {4, 1280 * time.Second}, - {5, 5120 * time.Second}, - {6, 20480 * time.Second}, - {7, 12 * time.Hour}, - {8, 12 * time.Hour}, - } { - Expect(backoffFor(c.attempts, 0)).To(Equal(c.want), "attempt %d", c.attempts) - } - }) - - It("applies jitter proportionally", func() { - base := backoffFor(2, 0) - Expect(backoffFor(2, 0.2)).To(Equal(time.Duration(float64(base) * 1.2))) - Expect(backoffFor(2, -0.2)).To(Equal(time.Duration(float64(base) * 0.8))) - }) - - It("keeps random jitter within +/-40%", func() { - lo := time.Duration(float64(320*time.Second) * 0.6) - hi := time.Duration(float64(320*time.Second) * 1.4) - for range 200 { - d := backoff(3) - Expect(d).To(BeNumerically(">=", lo)) - Expect(d).To(BeNumerically("<=", hi)) - } - }) -}) - -var _ = Describe("retryDelay", func() { - It("uses the backoff schedule when the provider asked for nothing", func() { - d := retryDelay(0, 0) - Expect(d).To(BeNumerically(">=", 3*time.Second)) - Expect(d).To(BeNumerically("<=", 7*time.Second)) - }) - - It("waits the provider's delay when it is longer than the backoff", func() { - Expect(retryDelay(0, time.Hour)).To(Equal(time.Hour)) - }) - - It("keeps the backoff when it is longer than the provider's delay", func() { - Expect(retryDelay(4, time.Second)).To(BeNumerically(">=", 3*time.Second)) - }) -}) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go deleted file mode 100644 index 63f12d03c..000000000 --- a/core/artwork/worker_timing_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package artwork - -import ( - "errors" - "io" - "testing" - "testing/synctest" - "time" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/gomega" -) - -// Drives the real breaker state machine with the fake clock. Plain test: testing/synctest -// needs a *testing.T, which Ginkgo doesn't give. -func TestArtworkBreakerHalfOpen(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - b := newBreaker() - - for range breakerThreshold { - b.record("agentA", 0, errors.New("boom")) - } - g.Expect(allowed(b)).To(BeFalse(), "breaker opens after consecutive errors") - - time.Sleep(breakerProbeAfter - time.Nanosecond) - g.Expect(allowed(b)).To(BeFalse(), "still open before the probe interval") - - time.Sleep(time.Nanosecond) - ok, gen := b.allow() - g.Expect(ok).To(BeTrue(), "half-open: one probe is granted") - g.Expect(gen).ToNot(BeZero(), "a probe carries the open episode it belongs to") - g.Expect(allowed(b)).To(BeFalse(), "only a single probe per interval") - - b.record("agentA", gen, errors.New("boom")) // probe fails -> stay open - time.Sleep(breakerProbeAfter) - ok, gen = b.allow() - g.Expect(ok).To(BeTrue(), "another probe after the next interval") - - // One good answer must not reopen the floodgates: closing here is what let a burst out at - // full rate and got the provider to escalate from throttling to blocking. - b.record("agentA", gen, nil) - g.Expect(allowed(b)).To(BeFalse(), "a single good answer does not close the breaker") - - for range breakerRecoveries - 1 { - time.Sleep(breakerProbeAfter) - ok, gen = b.allow() - g.Expect(ok).To(BeTrue()) - b.record("agentA", gen, nil) - } - g.Expect(allowed(b)).To(BeTrue(), "closed breaker admits freely") - g.Expect(allowed(b)).To(BeTrue()) - }) -} - -// The failure seen in production: while an agent was blocked, the occasional answer it did serve -// reset the breaker, releasing a burst that immediately re-tripped it. Open and closed pairs were -// seconds apart, over and over. -func TestArtworkBreakerDoesNotCloseOnAnIsolatedAnswer(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - b := newBreaker() - open := func() { - for range breakerThreshold { - b.record("agentA", 0, errors.New("blocked")) - } - } - open() - - // A not-found is an answer, so it counts toward recovery, but never on its own. - for range breakerRecoveries * 2 { - time.Sleep(breakerProbeAfter) - ok, gen := b.allow() - g.Expect(ok).To(BeTrue(), "one probe per interval") - b.record("agentA", gen, agents.ErrNotFound) - b.record("agentA", 0, errors.New("blocked")) // the very next call is refused again - g.Expect(allowed(b)).To(BeFalse(), "an answer between failures must not close the breaker") - } - }) -} - -func TestArtworkGatePerAgentBreakerIsolation(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - w := NewWorker(&tests.MockDataStore{}, NewImageStore(t.TempDir()), - agents.GetAgents(&tests.MockDataStore{}, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, nil) - - fail := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } - for range breakerThreshold { - _, _, _ = w.gate("A", fail) - } - _, _, err := w.gate("A", fail) - g.Expect(err).To(MatchError(errBreakerOpen), "A opens after consecutive errors") - - // B has its own breaker, untouched by A being open. - var bCalls int - bStep := func() (io.ReadCloser, string, error) { bCalls++; return nil, "", errors.New("boom") } - for range breakerThreshold - 1 { - _, _, err := w.gate("B", bStep) - g.Expect(err).To(MatchError("boom")) - } - g.Expect(bCalls).To(Equal(breakerThreshold-1), "B keeps being called while A is open") - - // After the probe window, A admits exactly one probe again. - time.Sleep(breakerProbeAfter) - var aCalls int - aFail := func() (io.ReadCloser, string, error) { aCalls++; return nil, "", errors.New("boom") } - _, _, _ = w.gate("A", aFail) - g.Expect(aCalls).To(Equal(1), "A grants a single probe after the interval") - _, _, err = w.gate("A", aFail) - g.Expect(err).To(MatchError(errBreakerOpen), "the probe failed, so A stays open") - g.Expect(aCalls).To(Equal(1)) - }) -} diff --git a/core/auth/auth.go b/core/auth/auth.go index 1bdc917da..7b3511bdf 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -4,8 +4,6 @@ import ( "cmp" "context" "crypto/sha256" - "errors" - "slices" "sync" "time" @@ -21,48 +19,35 @@ import ( ) var ( - once sync.Once - // TokenAuth signs UI/API session tokens. Rotated by the id migration so stale sessions die. + once sync.Once TokenAuth *jwtauth.JWTAuth - // PublicTokenAuth signs public-link tokens (artwork, share streams), on a separate secret that survives the rotation. - PublicTokenAuth *jwtauth.JWTAuth ) -// Audiences a session token can be scoped to. A token with no audience is accepted anywhere. -const ( - AudienceJellyfin = "jellyfin" - AudienceSubsonic = "subsonic" - AudienceNative = "native" -) - -// Init creates the JWTAuth objects from the secrets stored in the DB. -// Missing or undecryptable secrets are regenerated and stored. +// Init creates a JWTAuth object from the secret stored in the DB. +// If the secret is not found, it will create a new one and store it in the DB. func Init(ds model.DataStore) { once.Do(func() { ctx := context.TODO() log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout) - TokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTSecretKey)), nil) - PublicTokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTPublicSecretKey)), nil) - }) -} + secret, err := ds.Property(ctx).Get(consts.JWTSecretKey) + if err != nil || secret == "" { + log.Info(ctx, "Creating new JWT secret, used for encrypting UI sessions") + secret = createNewSecret(ctx, ds) + } else { + if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil { + log.Error(ctx, "Could not decrypt JWT secret, creating a new one", err) + secret = createNewSecret(ctx, ds) + } + } -func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string { - secret, err := ds.Property().Get(ctx, key) - if err != nil || secret == "" { - log.Info(ctx, "Creating new JWT secret", "key", key) - return createNewSecret(ctx, ds, key) - } - if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil { - log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err) - return createNewSecret(ctx, ds, key) - } - return secret + TokenAuth = jwtauth.New("HS256", []byte(secret), nil) + }) } func CreatePublicToken(claims Claims) (string, error) { claims.Issuer = consts.JWTIssuer - _, token, err := PublicTokenAuth.Encode(claims.ToMap()) + _, token, err := TokenAuth.Encode(claims.ToMap()) return token, err } @@ -71,24 +56,19 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) { if !exp.IsZero() { claims.ExpiresAt = exp } - _, token, err := PublicTokenAuth.Encode(claims.ToMap()) + _, token, err := TokenAuth.Encode(claims.ToMap()) return token, err } -func userClaims(u *model.User, audience []string) Claims { - return Claims{ +func CreateToken(u *model.User) (string, error) { + claims := Claims{ Issuer: consts.JWTIssuer, Subject: u.UserName, IssuedAt: time.Now(), UserID: u.ID, IsAdmin: u.IsAdmin, - Epoch: u.TokenEpoch, - Audience: audience, } -} - -func CreateToken(u *model.User) (string, error) { - token, _, err := TokenAuth.Encode(userClaims(u, nil).ToMap()) + token, _, err := TokenAuth.Encode(claims.ToMap()) if err != nil { return "", err } @@ -96,20 +76,10 @@ func CreateToken(u *model.User) (string, error) { return TouchToken(token) } -// CreateAPIToken mints a non-expiring token scoped to one API, matching how Jellyfin -// clients expect tokens to behave. Revocation is by token epoch, not expiry. -func CreateAPIToken(u *model.User, audience string) (string, error) { - _, token, err := TokenAuth.Encode(userClaims(u, []string{audience}).ToMap()) - return token, err -} - func TouchToken(token jwt.Token) (string, error) { - return TouchClaims(ClaimsFromToken(token)) -} - -func TouchClaims(c Claims) (string, error) { - c = c.WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout)) - _, newToken, err := TokenAuth.Encode(c.ToMap()) + claims := ClaimsFromToken(token). + WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout)) + _, newToken, err := TokenAuth.Encode(claims.ToMap()) return newToken, err } @@ -121,42 +91,10 @@ func Validate(tokenStr string) (Claims, error) { return ClaimsFromToken(token), nil } -// ValidatePublic verifies a public-link token against the public secret. -func ValidatePublic(tokenStr string) (Claims, error) { - token, err := jwtauth.VerifyToken(PublicTokenAuth, tokenStr) - if err != nil { - return Claims{}, err - } - return ClaimsFromToken(token), nil -} - -var ( - ErrTokenRevoked = errors.New("token revoked") - ErrWrongAudience = errors.New("token not valid for this API") - ErrWrongUser = errors.New("token issued for a different user") -) - -// CheckClaims gates a session token against the user it names. Callers must have already -// verified the signature; this adds revocation and API scoping on top. -func CheckClaims(c Claims, usr model.User, audience string) error { - // Usernames can be reused: deleting a user and recreating the name yields a new random id - // at epoch 0, which an old token would otherwise match. - if c.UserID != "" && c.UserID != usr.ID { - return ErrWrongUser - } - if c.Epoch != usr.TokenEpoch { - return ErrTokenRevoked - } - if len(c.Audience) > 0 && !slices.Contains(c.Audience, audience) { - return ErrWrongAudience - } - return nil -} - func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { - u, err := ds.User().FindFirstAdmin(ctx) + u, err := ds.User(ctx).FindFirstAdmin() if err != nil { - c, err := ds.User().CountAll(ctx) + c, err := ds.User(ctx).CountAll() if c == 0 && err == nil { log.Debug(ctx, "No admin user yet!", err) } else { @@ -169,14 +107,14 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { return request.WithUser(ctx, *u) } -func createNewSecret(ctx context.Context, ds model.DataStore, key string) string { +func createNewSecret(ctx context.Context, ds model.DataStore) string { secret := id.NewRandom() encSecret, err := utils.Encrypt(ctx, getEncKey(), secret) if err != nil { log.Error(ctx, "Could not encrypt JWT secret", err) return secret } - if err := ds.Property().Put(ctx, key, encSecret); err != nil { + if err := ds.Property(ctx).Put(consts.JWTSecretKey, encSecret); err != nil { log.Error(ctx, "Could not save JWT secret in DB", err) } return secret diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index c86dcd08c..761dd205c 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -21,7 +21,8 @@ func TestAuth(t *testing.T) { } const ( - oneDay = 24 * time.Hour + testJWTSecret = "not so secret" + oneDay = 24 * time.Hour ) var _ = BeforeSuite(func() { @@ -89,51 +90,6 @@ var _ = Describe("Auth", func() { }) }) - Describe("Session/Public secret split", func() { - claims := func() map[string]any { - return map[string]any{"iss": "issuer", "exp": time.Now().Add(1 * time.Minute).Unix()} - } - - It("verifies a session token via Validate but not ValidatePublic", func() { - _, tokenStr, err := auth.TokenAuth.Encode(claims()) - Expect(err).NotTo(HaveOccurred()) - _, err = auth.Validate(tokenStr) - Expect(err).NotTo(HaveOccurred()) - _, err = auth.ValidatePublic(tokenStr) - Expect(err).To(HaveOccurred()) - }) - - It("verifies a public token via ValidatePublic but not Validate", func() { - _, tokenStr, err := auth.PublicTokenAuth.Encode(claims()) - Expect(err).NotTo(HaveOccurred()) - _, err = auth.ValidatePublic(tokenStr) - Expect(err).NotTo(HaveOccurred()) - _, err = auth.Validate(tokenStr) - Expect(err).To(HaveOccurred()) - }) - - It("decodes public tokens minted by CreatePublicToken via PublicTokenAuth", func() { - tokenStr, err := auth.CreatePublicToken(auth.Claims{ID: "art-1"}) - Expect(err).NotTo(HaveOccurred()) - claims, err := auth.ValidatePublic(tokenStr) - Expect(err).NotTo(HaveOccurred()) - Expect(claims.ID).To(Equal("art-1")) - _, err = auth.Validate(tokenStr) - Expect(err).To(HaveOccurred()) - }) - - It("decodes expiring public tokens minted by CreateExpiringPublicToken via PublicTokenAuth", func() { - exp := time.Now().Add(1 * time.Hour) - tokenStr, err := auth.CreateExpiringPublicToken(exp, auth.Claims{ID: "art-2"}) - Expect(err).NotTo(HaveOccurred()) - claims, err := auth.ValidatePublic(tokenStr) - Expect(err).NotTo(HaveOccurred()) - Expect(claims.ID).To(Equal("art-2")) - _, err = auth.Validate(tokenStr) - Expect(err).To(HaveOccurred()) - }) - }) - Describe("TouchToken", func() { It("updates the expiration time", func() { yesterday := time.Now().Add(-oneDay) @@ -151,113 +107,4 @@ var _ = Describe("Auth", func() { Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay)) }) }) - - Describe("CreateAPIToken", func() { - var usr *model.User - - BeforeEach(func() { - usr = &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 4} - }) - - It("does not expire", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - - claims, err := auth.Validate(tokenStr) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.ExpiresAt.IsZero()).To(BeTrue()) - }) - - It("carries the audience and the user's epoch", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - - claims, err := auth.Validate(tokenStr) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Audience).To(Equal([]string{"jellyfin"})) - Expect(claims.Epoch).To(Equal(4)) - Expect(claims.Subject).To(Equal("johndoe")) - Expect(claims.UserID).To(Equal("123")) - }) - }) - - Describe("CreateToken with an epoch", func() { - It("carries the epoch and still expires", func() { - usr := &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 9} - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - - claims, err := auth.Validate(tokenStr) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Epoch).To(Equal(9)) - Expect(claims.Audience).To(BeEmpty()) - Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) - }) - }) - - Describe("TouchClaims", func() { - It("preserves custom claims and refreshes the expiry", func() { - tokenStr, err := auth.TouchClaims(auth.Claims{Subject: "johndoe", UserID: "123", Epoch: 5}) - Expect(err).ToNot(HaveOccurred()) - - claims, err := auth.Validate(tokenStr) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Epoch).To(Equal(5)) - Expect(claims.Subject).To(Equal("johndoe")) - Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) - }) - }) - - Describe("CheckClaims", func() { - usr := model.User{ID: "123", UserName: "johndoe", TokenEpoch: 2} - - It("accepts a matching epoch and audience", func() { - c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}} - Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed()) - }) - - It("accepts a token with no audience on any API", func() { - c := auth.Claims{Epoch: 2} - Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) - Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed()) - Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(Succeed()) - }) - - It("rejects a stale epoch", func() { - c := auth.Claims{Epoch: 1, Audience: []string{auth.AudienceJellyfin}} - Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(MatchError(auth.ErrTokenRevoked)) - }) - - It("rejects a token minted for another API", func() { - c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}} - Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(MatchError(auth.ErrWrongAudience)) - Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(MatchError(auth.ErrWrongAudience)) - }) - - It("accepts a multi-audience token that includes this API", func() { - c := auth.Claims{Epoch: 2, Audience: []string{"other", auth.AudienceNative}} - Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) - }) - - It("accepts a pre-upgrade token against a never-bumped user", func() { - fresh := model.User{ID: "456", UserName: "newbie"} - Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed()) - }) - - It("accepts a token whose user id matches", func() { - c := auth.Claims{UserID: "123", Epoch: 2} - Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) - }) - - It("rejects a token for a deleted user recreated under the same name", func() { - recreated := model.User{ID: "new-random-id", UserName: "johndoe"} - c := auth.Claims{UserID: "123", Audience: []string{auth.AudienceJellyfin}} - Expect(auth.CheckClaims(c, recreated, auth.AudienceJellyfin)).To(MatchError(auth.ErrWrongUser)) - }) - - It("accepts a token that carries no user id", func() { - fresh := model.User{ID: "456", UserName: "newbie"} - Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed()) - }) - }) }) diff --git a/core/auth/claims.go b/core/auth/claims.go index 42f7e4f2f..c7e6f02fe 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -11,8 +11,7 @@ import ( type Claims struct { // Standard JWT claims Issuer string - Subject string // username for session tokens - Audience []string // which API may accept this token; empty means any + Subject string // username for session tokens IssuedAt time.Time ExpiresAt time.Time @@ -23,7 +22,6 @@ type Claims struct { Format string // "f" - audio format BitRate int // "b" - audio bitrate ShareID string // "sid" - share ID for share stream tokens - Epoch int // "ep" - the user's token_epoch at mint time } // ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). @@ -36,9 +34,6 @@ func (c Claims) ToMap() map[string]any { if c.Subject != "" { m[jwt.SubjectKey] = c.Subject } - if len(c.Audience) > 0 { - m[jwt.AudienceKey] = c.Audience - } if !c.IssuedAt.IsZero() { m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix() } @@ -63,9 +58,6 @@ func (c Claims) ToMap() map[string]any { if c.ShareID != "" { m["sid"] = c.ShareID } - if c.Epoch != 0 { - m["ep"] = c.Epoch - } return m } @@ -81,7 +73,6 @@ func ClaimsFromToken(token jwt.Token) Claims { c.Subject, _ = token.Subject() c.IssuedAt, _ = token.IssuedAt() c.ExpiresAt, _ = token.Expiration() - c.Audience, _ = token.Audience() var uid string if err := token.Get("uid", &uid); err == nil { @@ -99,24 +90,15 @@ func ClaimsFromToken(token jwt.Token) Claims { if err := token.Get("f", &f); err == nil { c.Format = f } - c.BitRate = intClaim(token, "b") + if err := token.Get("b", &c.BitRate); err != nil { + var bf float64 + if err := token.Get("b", &bf); err == nil { + c.BitRate = int(bf) + } + } var sid string if err := token.Get("sid", &sid); err == nil { c.ShareID = sid } - c.Epoch = intClaim(token, "ep") return c } - -// intClaim reads a numeric claim, which a parsed token may decode as either int or float64. -func intClaim(token jwt.Token, key string) int { - var i int - if err := token.Get(key, &i); err == nil { - return i - } - var f float64 - if err := token.Get(key, &f); err == nil { - return int(f) - } - return 0 -} diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go index 69d054031..8820fd295 100644 --- a/core/auth/claims_test.go +++ b/core/auth/claims_test.go @@ -105,44 +105,4 @@ var _ = Describe("Claims", func() { }) }) - Describe("Audience and Epoch claims", func() { - It("omits both when zero", func() { - m := auth.Claims{ID: "artwork-id"}.ToMap() - Expect(m).ToNot(HaveKey("aud")) - Expect(m).ToNot(HaveKey("ep")) - }) - - It("includes them when set", func() { - m := auth.Claims{Subject: "u", Epoch: 3, Audience: []string{"jellyfin"}}.ToMap() - Expect(m).To(HaveKeyWithValue("ep", 3)) - Expect(m).To(HaveKeyWithValue("aud", []string{"jellyfin"})) - }) - - It("round-trips through a signed token", func() { - tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) - _, tokenStr, err := tokenAuth.Encode(auth.Claims{ - Subject: "u", Epoch: 7, Audience: []string{"jellyfin"}, - }.ToMap()) - Expect(err).ToNot(HaveOccurred()) - - token, err := jwtauth.VerifyToken(tokenAuth, tokenStr) - Expect(err).ToNot(HaveOccurred()) - claims := auth.ClaimsFromToken(token) - Expect(claims.Epoch).To(Equal(7)) - Expect(claims.Audience).To(Equal([]string{"jellyfin"})) - }) - - It("reads a token that has neither claim", func() { - tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) - _, tokenStr, err := tokenAuth.Encode(auth.Claims{Subject: "u"}.ToMap()) - Expect(err).ToNot(HaveOccurred()) - - token, err := jwtauth.VerifyToken(tokenAuth, tokenStr) - Expect(err).ToNot(HaveOccurred()) - claims := auth.ClaimsFromToken(token) - Expect(claims.Epoch).To(BeZero()) - Expect(claims.Audience).To(BeEmpty()) - }) - }) - }) diff --git a/core/common.go b/core/common.go index db2dbbf1b..6ff349b1b 100644 --- a/core/common.go +++ b/core/common.go @@ -19,7 +19,7 @@ func userName(ctx context.Context) string { // BFR We should only access files through the `storage.Storage` interface. This will require changing how // TagLib and ffmpeg access files var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string { - libPath, err := ds.Library().GetPath(ctx, libId) + libPath, err := ds.Library(ctx).GetPath(libId) if err != nil { return path } diff --git a/core/common_test.go b/core/common_test.go index f57ea8d38..0d6e3a299 100644 --- a/core/common_test.go +++ b/core/common_test.go @@ -2,7 +2,6 @@ package core import ( "context" - "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -42,9 +41,10 @@ var _ = Describe("common.go", func() { }) It("returns the absolute path when library exists", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-core)") ctx := context.Background() abs := AbsolutePath(ctx, ds, libId, path) - Expect(abs).To(Equal(filepath.FromSlash("/library/root/music/file.mp3"))) + Expect(abs).To(Equal("/library/root/music/file.mp3")) }) It("returns the original path if library not found", func() { diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go index 73d88e5b4..8fabf4490 100644 --- a/core/external/extdata_helper_test.go +++ b/core/external/extdata_helper_test.go @@ -6,7 +6,6 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" "github.com/stretchr/testify/mock" ) @@ -31,7 +30,7 @@ func (m *mockArtistRepo) SetData(artists model.Artists) { } // Get implements model.ArtistRepository. -func (m *mockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) { +func (m *mockArtistRepo) Get(id string) (*model.Artist, error) { args := m.Called(id) if args.Get(0) == nil { return nil, args.Error(1) @@ -40,7 +39,7 @@ func (m *mockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error } // GetAll implements model.ArtistRepository. -func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) { +func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v @@ -85,7 +84,7 @@ func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) { } // Get implements model.MediaFileRepository. -func (m *mockMediaFileRepo) Get(ctx context.Context, id string) (*model.MediaFile, error) { +func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) { args := m.Called(id) if args.Get(0) == nil { return nil, args.Error(1) @@ -94,25 +93,12 @@ func (m *mockMediaFileRepo) Get(ctx context.Context, id string) (*model.MediaFil } // GetAllByTags implements model.MediaFileRepository. -func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { - return m.GetAll(ctx, options...) +func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { + return m.GetAll(options...) } // GetAll implements model.MediaFileRepository. -func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, 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.MediaFiles), args.Error(1) -} - -// GetRandom implements model.MediaFileRepository. -func (m *mockMediaFileRepo) GetRandom(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v @@ -156,7 +142,7 @@ func newMockAlbumRepo() *mockAlbumRepo { } // Get implements model.AlbumRepository. -func (m *mockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) { +func (m *mockAlbumRepo) Get(id string) (*model.Album, error) { args := m.Called(id) if args.Get(0) == nil { return nil, args.Error(1) @@ -165,7 +151,7 @@ func (m *mockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) } // GetAll implements model.AlbumRepository. -func (m *mockAlbumRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Albums, error) { +func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) { argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v @@ -325,7 +311,3 @@ func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid } return nil, args.Error(1) } - -func ids(mfs model.MediaFiles) []string { - return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) -} diff --git a/core/external/provider.go b/core/external/provider.go index 185725259..4f3295cc7 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "slices" + "net/url" "sort" "strings" "time" @@ -15,9 +15,9 @@ import ( "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils" . "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/random" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" "golang.org/x/sync/errgroup" @@ -34,15 +34,15 @@ type Provider interface { UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) - TopSongs(ctx context.Context, artist, artistId string, count int) (model.MediaFiles, error) - RefreshInfo(ctx context.Context, kind model.Kind, id string) error + TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) + ArtistImage(ctx context.Context, id string) (*url.URL, error) + AlbumImage(ctx context.Context, id string) (*url.URL, error) } type provider struct { ds model.DataStore ag Agents matcher *matcher.Matcher - broker events.Broker artistQueue refreshQueue[auxArtist] albumQueue refreshQueue[auxAlbum] } @@ -87,17 +87,13 @@ type Agents interface { agents.SimilarSongsByArtistRetriever } -func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher, broker events.Broker) Provider { - e := &provider{ds: ds, ag: agents, matcher: m, broker: broker} +func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider { + e := &provider{ds: ds, ag: agents, matcher: m} e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo) e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo) return e } -func (e *provider) broadcastRefresh(ctx context.Context, resource, id string) { - e.broker.SendBroadcastMessage(ctx, (&events.RefreshResource{}).With(resource, id)) -} - func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) { var entity any entity, err := model.GetEntityByID(ctx, e.ds, id) @@ -148,8 +144,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl start := time.Now() albumName := album.Name() info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) - // Throttled joins not-found: no answer to store, and an unstamped timestamp retries next call. - if errors.Is(err, agents.ErrNotFound) || errors.Is(err, agents.ErrRetryLater) { + if errors.Is(err, agents.ErrNotFound) { return album, nil } if err != nil { @@ -158,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl return album, err } - album.ExternalInfoUpdatedAt = new(time.Now()) + album.ExternalInfoUpdatedAt = P(time.Now()) album.ExternalUrl = info.URL if info.Description != "" { @@ -182,13 +177,12 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl } } - err = e.ds.Album().UpdateExternalInfo(ctx, &album.Album) + err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album) if err != nil { log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName, "elapsed", time.Since(start), err) } else { log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start)) - e.broadcastRefresh(ctx, "album", album.ID) } return album, nil @@ -254,99 +248,210 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au start := time.Now() // Get MBID first, if it is not yet available artistName := artist.Name() - var mbidErr error if artist.MbzArtistID == "" { mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName) - mbidErr = err if mbid != "" && err == nil { artist.MbzArtistID = mbid } } - // Call all registered agents and collect information. The group carries no context, so a - // returned error does not cancel the siblings; only throttling is reported back. + // Call all registered agents and collect information g := errgroup.Group{} g.SetLimit(2) - g.Go(func() error { return retryLaterOnly(e.callGetImage(ctx, e.ag, &artist)) }) - g.Go(func() error { return retryLaterOnly(e.callGetBiography(ctx, e.ag, &artist)) }) - g.Go(func() error { return retryLaterOnly(e.callGetURL(ctx, e.ag, &artist)) }) - g.Go(func() error { - return retryLaterOnly(e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true)) - }) - throttled := errors.Is(g.Wait(), agents.ErrRetryLater) || errors.Is(mbidErr, agents.ErrRetryLater) + g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil }) + g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil }) + g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil }) + g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil }) + _ = g.Wait() if utils.IsCtxDone(ctx) { log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err()) return artist, ctx.Err() } - // A throttled round keeps the previous timestamp, so the next call retries instead of - // serving an empty cache entry for the whole TTL. - if !throttled { - artist.ExternalInfoUpdatedAt = new(time.Now()) - } - err := e.ds.Artist().UpdateExternalInfo(ctx, &artist.Artist) + artist.ExternalInfoUpdatedAt = P(time.Now()) + err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) if err != nil { log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), err) } else { log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start)) - e.broadcastRefresh(ctx, "artist", artist.ID) } return artist, nil } -// infoKinds are the kinds RefreshInfo can act on. Callers check this instead of restating -// the set, so the switch below stays the only place that has to know how each kind loads. -var infoKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork} - -// HasInfo reports whether a kind has external info to refresh. -func HasInfo(kind model.Kind) bool { return slices.Contains(infoKinds, kind) } - -// RefreshInfo re-fetches external info for one item, ignoring the TTL. It is synchronous: -// callers that must not block are responsible for detaching it. -func (e *provider) RefreshInfo(ctx context.Context, kind model.Kind, id string) error { - ctx, cancel := context.WithTimeout(ctx, refreshTimeout) - defer cancel() - - switch kind { - case model.KindArtistArtwork: - artist, err := e.getArtist(ctx, id) - if err != nil { - return err - } - _, err = e.populateArtistInfo(ctx, artist) - return err - case model.KindAlbumArtwork: - album, err := e.getAlbum(ctx, id) - if err != nil { - return err - } - _, err = e.populateAlbumInfo(ctx, album) - return err - default: - return model.ErrNotFound +func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { + entity, err := model.GetEntityByID(ctx, e.ds, id) + if err != nil { + return nil, err } + + var songs []agents.Song + + // Try entity-specific similarity first + switch v := entity.(type) { + case *model.MediaFile: + songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count) + case *model.Album: + songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count) + case *model.Artist: + songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count) + default: + log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity)) + return nil, model.ErrNotFound + } + + if err == nil && len(songs) > 0 { + return e.matcher.MatchSongs(ctx, songs, count) + } + + // Fallback to existing similar artists + top songs algorithm + return e.similarSongsFallback(ctx, id, count) } -func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) { - artist, err := e.findArtist(ctx, artistName, id) +// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to +// get the artist of the given entity, retrieve similar artists, get their top songs, and pick +// a weighted random selection of songs to return as similar songs. +func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) { + artist, err := e.getArtist(ctx, id) if err != nil { - if errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Artist not found", "name", artistName, "id", id, err) - return nil, nil + return nil, err + } + + e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) + return nil, ctx.Err() + } + + weightedSongs := random.NewWeightedChooser[model.MediaFile]() + addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error { + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) + return ctx.Err() } - log.Error(ctx, "Failure occurred when trying to fetch artist", "name", artistName, "id", id, err) + topCount := max(count, 20) + topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount) + if err != nil { + log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err) + return nil + } + + weight := topCount * (4 + artistWeight) + for _, mf := range topSongs { + weightedSongs.Add(mf, weight) + weight -= 4 + } + return nil + } + + err = addArtist(artist.Artist, weightedSongs, count, 10) + if err != nil { return nil, err } + for _, a := range artist.SimilarArtists { + err := addArtist(a, weightedSongs, count, 0) + if err != nil { + return nil, err + } + } + + var similarSongs model.MediaFiles + for len(similarSongs) < count && weightedSongs.Size() > 0 { + s, err := weightedSongs.Pick() + if err != nil { + log.Warn(ctx, "Error getting weighted song", err) + continue + } + similarSongs = append(similarSongs, s) + } + + return similarSongs, nil +} + +func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) { + artist, err := e.getArtist(ctx, id) + if err != nil { + return nil, err + } + + imageUrl := artist.ArtistImageUrl() + if imageUrl == "" { + // No cached URL — must fetch from external source synchronously + e.callGetImage(ctx, e.ag, &artist) + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "ArtistImage call canceled", ctx.Err()) + return nil, ctx.Err() + } + imageUrl = artist.ArtistImageUrl() + } else { + // If cached info is expired, enqueue a background refresh so that config changes + // (e.g. disabling an agent) take effect without waiting for a full artist info refresh. + updatedAt := V(artist.ExternalInfoUpdatedAt) + if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive { + log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt) + e.artistQueue.enqueue(&artist) + } + } + + if imageUrl == "" { + return nil, model.ErrNotFound + } + return url.Parse(imageUrl) +} + +func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) { + album, err := e.getAlbum(ctx, id) + if err != nil { + return nil, err + } + + albumName := album.Name() + images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) + if err != nil { + switch { + case errors.Is(err, agents.ErrNotFound): + log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist) + return nil, model.ErrNotFound + case errors.Is(err, context.Canceled): + log.Debug(ctx, "GetAlbumImages call canceled", err) + default: + log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err) + } + return nil, err + } + + if len(images) == 0 { + log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist) + return nil, model.ErrNotFound + } + + // Return the biggest image + var img agents.ExternalImage + for _, i := range images { + if img.Size <= i.Size { + img = i + } + } + if img.URL == "" { + return nil, model.ErrNotFound + } + return url.Parse(img.URL) +} + +func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) { + artist, err := e.findArtistByName(ctx, artistName) + if err != nil { + log.Error(ctx, "Artist not found", "name", artistName, err) + return nil, nil + } songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count) if err != nil { switch { - // Throttled is not an answer, but the caller keeps the empty 200 it got before. - case errors.Is(err, agents.ErrNotFound), errors.Is(err, agents.ErrRetryLater): - log.Trace(ctx, "TopSongs not found", "name", artistName, err) + case errors.Is(err, agents.ErrNotFound): + log.Trace(ctx, "TopSongs not found", "name", artistName) return nil, model.ErrNotFound case errors.Is(err, context.Canceled): log.Debug(ctx, "TopSongs call canceled", err) @@ -366,19 +471,13 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err) } - // Enrich top songs with the queried artist. A song with no artists, or whose first credit the - // agent left unnamed, is attributed to the queried artist. A first credit that already names an - // artist is left as-is: it may be a different (e.g. featured) artist, so stamping the queried - // MBID onto it would create a false name+MBID pairing. + // Enrich songs with artist info if not already present (for top songs, we know the artist) for i := range songs { - switch { - case len(songs[i].Artists) == 0: - songs[i].Artists = []agents.Artist{{Name: artistName, MBID: artist.MbzArtistID}} - case songs[i].Artists[0].Name == "": - songs[i].Artists[0].Name = artistName - if songs[i].Artists[0].MBID == "" { - songs[i].Artists[0].MBID = artist.MbzArtistID - } + if songs[i].Artist == "" { + songs[i].Artist = artistName + } + if songs[i].ArtistMBID == "" { + songs[i].ArtistMBID = artist.MbzArtistID } } @@ -396,44 +495,28 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return mfs, nil } -// retryLaterOnly discards every failure the caller does not act on, so errgroup's -// first-error slot is reserved for the throttling signal. -func retryLaterOnly(err error) error { - if errors.Is(err, agents.ErrRetryLater) { - return err - } - return nil -} - -func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) error { +func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) if err != nil { - return err + return } artist.ExternalUrl = artisURL - return nil } -func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) error { +func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) { bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID) if err != nil { - return err + return } bio = str.SanitizeText(bio) bio = strings.ReplaceAll(bio, "\n", " ") artist.Biography = strings.ReplaceAll(bio, " images[j].Size }) @@ -446,24 +529,22 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet if len(images) >= 3 { artist.SmallImageUrl = images[2].URL } - return nil } func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, - limit int, includeNotPresent bool) error { + limit int, includeNotPresent bool) { artistName := artist.Name() similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit) if len(similar) == 0 || err != nil { - return err + return } start := time.Now() sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent) log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start)) if err != nil { - return err + return } artist.SimilarArtists = sa - return nil } func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) { @@ -548,7 +629,7 @@ func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist) if len(ids) == 0 { return matches, nil } - res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ + res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"artist.id": ids}, }) if err != nil { @@ -577,7 +658,7 @@ func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artis if len(mbids) == 0 { return matches, nil } - res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ + res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"mbz_artist_id": mbids}, }) if err != nil { @@ -612,7 +693,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis clauses := slice.Map(names, func(name string) squirrel.Sqlizer { return squirrel.Like{"artist.name": name} }) - res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ + res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Or(clauses), }) if err != nil { @@ -626,25 +707,8 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis return matches, nil } -func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) { - if id != "" { - artist, err := e.ds.Artist().Get(ctx, id) - if err == nil { - return &auxArtist{Artist: *artist}, nil - } - - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Could not find artist by id", "id", id, err) - } else { - return nil, err - } - } - - if artistName == "" { - return nil, model.ErrNotFound - } - - artists, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ +func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) { + artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Like{"artist.name": artistName}, Max: 1, }) @@ -666,7 +730,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int ids = append(ids, sa.ID) } - similar, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ + similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"artist.id": ids}, }) if err != nil { diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go new file mode 100644 index 000000000..e801b7cce --- /dev/null +++ b/core/external/provider_albumimage_test.go @@ -0,0 +1,365 @@ +package external_test + +import ( + "context" + "errors" + "net/url" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Provider - AlbumImage", func() { + var ds *tests.MockDataStore + var provider Provider + var mockArtistRepo *mockArtistRepo + var mockAlbumRepo *mockAlbumRepo + var mockMediaFileRepo *mockMediaFileRepo + var mockAlbumAgent *mockAlbumInfoAgent + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + conf.Server.Agents = "mockAlbum" // Configure mock agent + + mockArtistRepo = newMockArtistRepo() + mockAlbumRepo = newMockAlbumRepo() + mockMediaFileRepo = newMockMediaFileRepo() + + ds = &tests.MockDataStore{ + MockedArtist: mockArtistRepo, + MockedAlbum: mockAlbumRepo, + MockedMediaFile: mockMediaFileRepo, + } + + mockAlbumAgent = newMockAlbumInfoAgent() + + agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent} + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) + + // Default mocks + // Mocks for GetEntityByID sequence (initial failed lookups) + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + + // Default mock for non-existent entities - Use Maybe() for flexibility + mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + }) + + It("returns the largest image URL when successful", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + {URL: "http://example.com/small.jpg", Size: 200}, + }, nil).Once() + + expectedURL, _ := url.Parse("http://example.com/large.jpg") + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name + }) + + It("returns ErrNotFound if the album is not found in the DB", func() { + // Arrange: Explicitly expect the full GetEntityByID sequence for "not-found" + mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + + imgURL, err := provider.AlbumImage(ctx, "not-found") + + Expect(err).To(MatchError("data not found")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) + }) + + It("returns the agent error if the agent fails", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + + agentErr := errors.New("agent failure") + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist + + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).To(MatchError("agent failure")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist + }) + + It("returns ErrNotFound if the agent returns ErrNotFound", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist + + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).To(MatchError("data not found")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist + }) + + It("returns ErrNotFound if the agent returns no images", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist + + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).To(MatchError("data not found")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist + }) + + It("returns context error if context is canceled", func() { + // Arrange + cctx, cancelCtx := context.WithCancel(ctx) + // Mock the necessary DB calls *before* canceling the context + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + // Expect the agent call even if context is cancelled, returning the context error + mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once() + // Cancel the context *before* calling the function under test + cancelCtx() + + imgURL, err := provider.AlbumImage(cctx, "album-1") + + Expect(err).To(MatchError("context canceled")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + // Agent should now be called, verify this expectation + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "") + }) + + It("derives album ID from MediaFile ID", func() { + // Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1" + mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once() + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + {URL: "http://example.com/small.jpg", Size: 200}, + }, nil).Once() + + expectedURL, _ := url.Parse("http://example.com/large.jpg") + imgURL, err := provider.AlbumImage(ctx, "mf-1") + + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1") + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") + }) + + It("handles different image orders from agent", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/small.jpg", Size: 200}, + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + }, nil).Once() + + expectedURL, _ := url.Parse("http://example.com/large.jpg") + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") + }) + + It("handles agent returning only one image", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/single.jpg", Size: 700}, + }, nil).Once() + + expectedURL, _ := url.Parse("http://example.com/single.jpg") + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") + }) + + It("returns ErrNotFound if deriving album ID fails", func() { + // Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found" + mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once() + mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once() + mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + + imgURL, err := provider.AlbumImage(ctx, "mf-no-album") + + Expect(err).To(MatchError("data not found")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) + }) + + Context("Unicode handling in album names", func() { + var albumWithEnDash *model.Album + var expectedURL *url.URL + + const ( + originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash + normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen + ) + + BeforeEach(func() { + // Test with en dash (–) in album name + albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"} + mockArtistRepo.Mock = mock.Mock{} // Reset default expectations + mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations + mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once() + + expectedURL, _ = url.Parse("http://example.com/album.jpg") + + // Mock the album agent to return an image for the album + mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/album.jpg", Size: 1000}, + }, nil).Once() + }) + + When("DevPreserveUnicodeInExternalCalls is true", func() { + BeforeEach(func() { + conf.Server.DevPreserveUnicodeInExternalCalls = true + }) + + It("preserves Unicode characters in album names", func() { + // Act + imgURL, err := provider.AlbumImage(ctx, "album-endash") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash") + // This is the key assertion: ensure the original Unicode name is used + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "") + }) + }) + + When("DevPreserveUnicodeInExternalCalls is false", func() { + BeforeEach(func() { + conf.Server.DevPreserveUnicodeInExternalCalls = false + }) + + It("normalizes Unicode characters", func() { + // Act + imgURL, err := provider.AlbumImage(ctx, "album-endash") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash") + // This assertion ensures the normalized name is used (en dash → hyphen) + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "") + }) + }) + }) +}) + +// mockAlbumInfoAgent implementation +type mockAlbumInfoAgent struct { + mock.Mock + agents.AlbumInfoRetriever + agents.AlbumImageRetriever +} + +func newMockAlbumInfoAgent() *mockAlbumInfoAgent { + m := new(mockAlbumInfoAgent) + m.On("AgentName").Return("mockAlbum").Maybe() + return m +} + +func (m *mockAlbumInfoAgent) AgentName() string { + args := m.Called() + return args.String(0) +} + +func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { + args := m.Called(ctx, name, artist, mbid) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*agents.AlbumInfo), args.Error(1) +} + +func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { + args := m.Called(ctx, name, artist, mbid) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]agents.ExternalImage), args.Error(1) +} + +// Ensure mockAgent implements the interfaces +var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil) +var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil) diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go new file mode 100644 index 000000000..37d3fd81a --- /dev/null +++ b/core/external/provider_artistimage_test.go @@ -0,0 +1,428 @@ +package external_test + +import ( + "bytes" + "context" + "errors" + "net/url" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Provider - ArtistImage", func() { + var ds *tests.MockDataStore + var provider Provider + var mockArtistRepo *mockArtistRepo + var mockAlbumRepo *mockAlbumRepo + var mockMediaFileRepo *mockMediaFileRepo + var mockImageAgent *mockArtistImageAgent + var agentsCombined *mockAgents + var ctx context.Context + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Agents = "mockImage" // Configure only the mock agent + ctx = GinkgoT().Context() + + mockArtistRepo = newMockArtistRepo() + mockAlbumRepo = newMockAlbumRepo() + mockMediaFileRepo = newMockMediaFileRepo() + + ds = &tests.MockDataStore{ + MockedArtist: mockArtistRepo, + MockedAlbum: mockAlbumRepo, + MockedMediaFile: mockMediaFileRepo, + } + + mockImageAgent = newMockArtistImageAgent() + + // Use the mockAgents from helper, setting the specific agent + agentsCombined = &mockAgents{ + imageAgent: mockImageAgent, + } + + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) + + // Default mocks for successful Get calls + mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe() + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Maybe() + mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1"}, nil).Maybe() + // Default mock for non-existent entities + mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + + // Default successful image agent response + mockImageAgent.On("GetArtistImages", mock.Anything, "artist-1", "Artist One", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + {URL: "http://example.com/small.jpg", Size: 200}, + }, nil).Maybe() + }) + + AfterEach(func() { + mockArtistRepo.AssertExpectations(GinkgoT()) + mockAlbumRepo.AssertExpectations(GinkgoT()) + mockMediaFileRepo.AssertExpectations(GinkgoT()) + mockImageAgent.AssertExpectations(GinkgoT()) + }) + + It("returns the largest image URL when successful", func() { + // Arrange + expectedURL, _ := url.Parse("http://example.com/large.jpg") + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("returns ErrNotFound if the artist is not found in the DB", func() { + // Arrange + + // Act + imgURL, err := provider.ArtistImage(ctx, "not-found") + + // Assert + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + + It("returns the agent error if the agent fails", func() { + // Arrange + agentErr := errors.New("agent failure") + mockImageAgent.Mock = mock.Mock{} // Reset default expectation + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once() + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).To(MatchError(model.ErrNotFound)) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("returns ErrNotFound if the agent returns ErrNotFound", func() { + // Arrange + mockImageAgent.Mock = mock.Mock{} // Reset default expectation + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once() + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("returns ErrNotFound if the agent returns no images", func() { + // Arrange + mockImageAgent.Mock = mock.Mock{} // Reset default expectation + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once() + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).To(MatchError(model.ErrNotFound)) // Implementation maps empty result to ErrNotFound + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("returns context error if context is canceled before agent call", func() { + // Arrange + cctx, cancelCtx := context.WithCancel(context.Background()) + mockArtistRepo.Mock = mock.Mock{} // Reset default expectation for artist repo as well + mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Run(func(args mock.Arguments) { + cancelCtx() // Cancel context *during* the DB call simulation + }).Once() + + // Act + imgURL, err := provider.ArtistImage(cctx, "artist-1") + + // Assert + Expect(err).To(MatchError(context.Canceled)) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + }) + + It("derives artist ID from MediaFile ID", func() { + // Arrange: Add mocks for the initial GetEntityByID lookups + mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + // Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest + expectedURL, _ := url.Parse("http://example.com/large.jpg") + + // Act + imgURL, err := provider.ArtistImage(ctx, "mf-1") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1") + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting MF + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("derives artist ID from Album ID", func() { + // Arrange: Add mock for the initial GetEntityByID lookup + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + // Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest + expectedURL, _ := url.Parse("http://example.com/large.jpg") + + // Act + imgURL, err := provider.ArtistImage(ctx, "album-1") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // GetEntityByID sequence + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting Album + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("returns ErrNotFound if derived artist is not found", func() { + // Arrange + // Add mocks for the initial GetEntityByID lookups + mockArtistRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() + mockMediaFileRepo.On("Get", "mf-bad-artist").Return(&model.MediaFile{ID: "mf-bad-artist", ArtistID: "not-found"}, nil).Once() + // Add expectation for the recursive GetEntityByID call for the MediaFileRepo + mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + // The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups + + // Act + imgURL, err := provider.ArtistImage(ctx, "mf-bad-artist") + + // Assert + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + + It("handles different image orders from agent", func() { + // Arrange + mockImageAgent.Mock = mock.Mock{} // Reset default expectation + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/small.jpg", Size: 200}, + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + }, nil).Once() + expectedURL, _ := url.Parse("http://example.com/large.jpg") + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) // Still picks the largest + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("handles agent returning only one image", func() { + // Arrange + mockImageAgent.Mock = mock.Mock{} // Reset default expectation + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/medium.jpg", Size: 500}, + }, nil).Once() + expectedURL, _ := url.Parse("http://example.com/medium.jpg") + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + 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, + } + mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() + expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") + + // Capture log output + var logBuf bytes.Buffer + log.SetOutput(&logBuf) + defer log.SetOutput(GinkgoWriter) + log.SetLevel(log.LevelDebug) + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-cached") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything) + + // Assert: background refresh was NOT enqueued + Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh")) + + }) + + 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, + } + mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() + expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") + + // Capture log output + var logBuf bytes.Buffer + log.SetOutput(&logBuf) + defer log.SetOutput(GinkgoWriter) + log.SetLevel(log.LevelDebug) + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-expired") + + // Assert: returns stale URL immediately, no agent call + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything) + + // Assert: background refresh was enqueued + Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh")) + }) + + Context("Unicode handling in artist names", func() { + var artistWithEnDash *model.Artist + var expectedURL *url.URL + + const ( + originalArtistName = "Run–D.M.C." // Artist name with en dash + normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen + ) + + BeforeEach(func() { + // Test with en dash (–) in artist name like "Run–D.M.C." + artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName} + mockArtistRepo.Mock = mock.Mock{} // Reset default expectations + mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once() + + expectedURL, _ = url.Parse("http://example.com/rundmc.jpg") + + // Mock the image agent to return an image for the artist + mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/rundmc.jpg", Size: 1000}, + }, nil).Once() + + }) + + When("DevPreserveUnicodeInExternalCalls is true", func() { + BeforeEach(func() { + conf.Server.DevPreserveUnicodeInExternalCalls = true + }) + It("preserves Unicode characters in artist names", func() { + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-endash") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") + // This is the key assertion: ensure the original Unicode name is used + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "") + }) + }) + + When("DevPreserveUnicodeInExternalCalls is false", func() { + BeforeEach(func() { + conf.Server.DevPreserveUnicodeInExternalCalls = false + }) + + It("normalizes Unicode characters", func() { + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-endash") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") + // This assertion ensures the normalized name is used (en dash → hyphen) + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "") + }) + }) + }) +}) + +// mockArtistImageAgent implementation using testify/mock +// This remains local as it's specific to testing the ArtistImage functionality +type mockArtistImageAgent struct { + mock.Mock + agents.ArtistImageRetriever // Embed interface +} + +// Constructor for the mock agent +func newMockArtistImageAgent() *mockArtistImageAgent { + mock := new(mockArtistImageAgent) + // Set default AgentName if needed, although usually called via mockAgents + mock.On("AgentName").Return("mockImage").Maybe() + return mock +} + +func (m *mockArtistImageAgent) AgentName() string { + args := m.Called() + return args.String(0) +} + +func (m *mockArtistImageAgent) GetArtistImages(ctx context.Context, id, artistName, mbid string) ([]agents.ExternalImage, error) { + args := m.Called(ctx, id, artistName, mbid) + // Need careful type assertion for potentially nil slice + var res []agents.ExternalImage + if args.Get(0) != nil { + res = args.Get(0).([]agents.ExternalImage) + } + return res, args.Error(1) +} + +// Ensure mockAgent implements the interface +var _ agents.ArtistImageRetriever = (*mockArtistImageAgent)(nil) diff --git a/core/external/provider_refreshinfo_test.go b/core/external/provider_refreshinfo_test.go deleted file mode 100644 index a1216dd94..000000000 --- a/core/external/provider_refreshinfo_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package external_test - -import ( - "context" - "slices" - "sync" - "time" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/matcher" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -type fakeBroker struct { - events.Broker - mu sync.Mutex - events []events.Event -} - -func (f *fakeBroker) SendBroadcastMessage(_ context.Context, e events.Event) { - f.mu.Lock() - defer f.mu.Unlock() - f.events = append(f.events, e) -} - -func (f *fakeBroker) sent() []events.Event { - f.mu.Lock() - defer f.mu.Unlock() - return slices.Clone(f.events) -} - -var _ = Describe("Provider - RefreshInfo", func() { - var ( - ctx context.Context - p external.Provider - ds *tests.MockDataStore - ag *mockAgents - broker *fakeBroker - mockArtistRepo *tests.MockArtistRepo - mockAlbumRepo *tests.MockAlbumRepo - ) - - expectArtistAgents := func() { - ag.On("GetArtistMBID", mock.Anything, mock.Anything, mock.Anything).Return("mbid-1", nil) - ag.On("GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.ExternalImage{}, nil) - ag.On("GetArtistBiography", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return("Fresh Bio", nil) - ag.On("GetArtistURL", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return("http://artist.url", nil) - ag.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Artist{}, nil) - } - - expectAlbumAgents := func() { - ag.On("GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&agents.AlbumInfo{URL: "http://album.url", Description: "Fresh Notes"}, nil) - ag.On("GetAlbumImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.ExternalImage{}, nil) - } - - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = new(tests.MockDataStore) - ag = new(mockAgents) - broker = &fakeBroker{} - p = external.NewProvider(ds, ag, matcher.New(ds), broker) - mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) - mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) - }) - - It("repopulates an artist even when its info is fresh", func() { - fresh := time.Now() - mockArtistRepo.SetData(model.Artists{{ - ID: "ar-1", Name: "Test Artist", Biography: "stale", ExternalInfoUpdatedAt: &fresh, - }}) - expectArtistAgents() - - Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed()) - - saved, err := mockArtistRepo.Get(ctx, "ar-1") - Expect(err).ToNot(HaveOccurred()) - Expect(saved.Biography).To(Equal("Fresh Bio")) - }) - - It("repopulates an album even when its info is fresh", func() { - fresh := time.Now() - mockAlbumRepo.SetData(model.Albums{{ - ID: "al-1", Name: "Test Album", AlbumArtist: "Test Artist", - Description: "stale", ExternalInfoUpdatedAt: &fresh, - }}) - expectAlbumAgents() - - Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed()) - - saved, err := mockAlbumRepo.Get(ctx, "al-1") - Expect(err).ToNot(HaveOccurred()) - Expect(saved.Description).To(Equal("Fresh Notes")) - }) - - It("returns ErrNotFound for an unknown id", func() { - Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "nope")).To(MatchError(model.ErrNotFound)) - }) - - It("returns ErrNotFound for a kind with no external info", func() { - Expect(p.RefreshInfo(ctx, model.KindPlaylistArtwork, "pl-1")).To(MatchError(model.ErrNotFound)) - }) - - It("broadcasts a RefreshResource naming the artist", func() { - mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Test Artist"}}) - expectArtistAgents() - - Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed()) - - sent := broker.sent() - Expect(sent).To(HaveLen(1)) - rr, ok := sent[0].(*events.RefreshResource) - Expect(ok).To(BeTrue()) - Expect(rr.Data(rr)).To(ContainSubstring("ar-1")) - Expect(rr.Data(rr)).To(ContainSubstring("artist")) - }) - - It("broadcasts a RefreshResource naming the album", func() { - mockAlbumRepo.SetData(model.Albums{{ID: "al-1", Name: "Test Album", AlbumArtist: "Test Artist"}}) - expectAlbumAgents() - - Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed()) - - sent := broker.sent() - Expect(sent).To(HaveLen(1)) - Expect(sent[0].Data(sent[0])).To(ContainSubstring("album")) - Expect(sent[0].Data(sent[0])).To(ContainSubstring("al-1")) - }) - - It("does not broadcast when the artist cannot be loaded", func() { - mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Test Artist"}}) - expectArtistAgents() - mockArtistRepo.SetError(true) - - _ = p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1") - - Expect(broker.sent()).To(BeEmpty()) - }) - - It("reports which kinds have external info", func() { - Expect(external.HasInfo(model.KindArtistArtwork)).To(BeTrue()) - Expect(external.HasInfo(model.KindAlbumArtwork)).To(BeTrue()) - Expect(external.HasInfo(model.KindPlaylistArtwork)).To(BeFalse()) - }) -}) diff --git a/core/external/provider_similarsongs.go b/core/external/provider_similarsongs.go deleted file mode 100644 index 305b8d6ec..000000000 --- a/core/external/provider_similarsongs.go +++ /dev/null @@ -1,300 +0,0 @@ -package external - -import ( - "context" - "errors" - "fmt" - "math/rand/v2" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/random" - "github.com/navidrome/navidrome/utils/slice" - "golang.org/x/sync/errgroup" -) - -const ( - maxSeeds = 5 - // Subsonic passes the client's count through unbounded, and it ends up as a SQL limit. 500 is - // what the widest caller (similarAlbums, limit*5) legitimately asks for. - maxSimilarSongs = 500 -) - -func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { - // Subsonic passes the client's count straight through: a non-positive one has no valid - // interpretation, and an enormous one overflows the +1 in the local agent's query limit. - if count <= 0 { - return nil, nil - } - count = min(count, maxSimilarSongs) - entity, err := model.GetEntityByID(ctx, e.ds, id) - if err != nil { - // Genre ids don't resolve via GetEntityByID; look them up before giving up. - if !errors.Is(err, model.ErrNotFound) { - return nil, err - } - genre, err := e.ds.Genre().Get(ctx, id) - if err != nil { - return nil, err - } - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.sampleGenreTracks(ctx, genre, maxSeeds) - }) - } - - // Try entity-specific similarity first, then fall back to seed-track sampling. - switch v := entity.(type) { - case *model.MediaFile: - return e.mixFromAgent(ctx, count, - func() ([]agents.Song, error) { - return e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count) - }, - func() (model.MediaFiles, error) { - return e.similarSongsFallback(ctx, id, count) - }) - case *model.Album: - return e.mixFromAgent(ctx, count, - func() ([]agents.Song, error) { - return e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count) - }, - func() (model.MediaFiles, error) { - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.sampleAlbumTracks(ctx, v.ID, maxSeeds) - }) - }) - case *model.Artist: - return e.mixFromAgent(ctx, count, - func() ([]agents.Song, error) { - return e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count) - }, - func() (model.MediaFiles, error) { - return e.similarSongsFallback(ctx, id, count) - }, - func() (model.MediaFiles, error) { - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.sampleArtistTracks(ctx, v.ID, maxSeeds) - }) - }) - case *model.Playlist: - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.samplePlaylistTracks(ctx, v.ID, maxSeeds) - }) - default: - log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity)) - return nil, model.ErrNotFound - } -} - -// mixFromAgent returns the agent's recommendations matched to library tracks, topped up from the -// fallbacks in order when they don't fill the mix on their own. -func (e *provider) mixFromAgent(ctx context.Context, count int, fetch func() ([]agents.Song, error), fallbacks ...func() (model.MediaFiles, error)) (model.MediaFiles, error) { - var matched model.MediaFiles - if songs, err := fetch(); err == nil { - // Match the whole response: capping at count can stop before a later unique pick. - matched, err = e.matcher.MatchSongs(ctx, songs, len(songs)) - if err != nil { - return nil, err - } - } - return topUp(ctx, matched, count, fallbacks...) -} - -// topUp draws on each source in turn until the mix holds count distinct tracks. -func topUp(ctx context.Context, res model.MediaFiles, count int, sources ...func() (model.MediaFiles, error)) (model.MediaFiles, error) { - // The matcher can re-emit a track, so a full-looking res may hold fewer than count unique ones. - res = dedupByID(res) - var lastErr error - for _, more := range sources { - if len(res) >= count { - break - } - extra, err := more() - if err != nil { - log.Debug(ctx, "Could not top up a short mix", "have", len(res), "want", count, err) - lastErr = err - continue - } - res = dedupByID(append(res, extra...)) - } - if len(res) == 0 { - return nil, lastErr - } - return res[:min(len(res), count)], nil -} - -// seedMix samples seed tracks, runs each through the agent chain's per-track similarity and merges -// the results, falling back to the seeds themselves so the result is never empty. -func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.MediaFiles, error)) (model.MediaFiles, error) { - seeds, err := sample() - if err != nil { - return nil, err - } - if len(seeds) == 0 { - return nil, nil - } - seeds = seeds[:min(len(seeds), maxSeeds)] - - // The per-seed similarity calls are independent and hit the (possibly remote) agent chain, so - // run them concurrently. Best-effort: a seed that errors just contributes nothing. - perSeed := make([][]agents.Song, len(seeds)) - var g errgroup.Group - for i, seed := range seeds { - g.Go(func() error { - if s, err := e.ag.GetSimilarSongsByTrack(ctx, seed.ID, seed.Title, seed.Artist, seed.MbzRecordingID, count); err == nil { - perSeed[i] = s - } - return nil - }) - } - _ = g.Wait() - - var songs []agents.Song - for _, s := range perSeed { - songs = append(songs, s...) - } - // Match the whole merged set, not just count of it: the matcher re-emits a track when two - // seeds recommend it identically, so the duplicates have to be dropped before trimming. Every - // seed reaches the shuffle, so no seed can crowd out the others. - matched, err := e.matcher.MatchSongs(ctx, songs, len(songs)) - if err != nil { - return nil, err - } - matched = dedupByID(matched) - if len(matched) == 0 { - matched = seeds - } - //nolint:gosec // shuffle order is not a security decision - rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] }) - if len(matched) > count { - matched = matched[:count] - } - return matched, nil -} - -func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) { - // Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would - // mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay. - repo := e.ds.Playlist().Tracks(ctx, playlistID, true) - if repo == nil { - return nil, model.ErrNotFound - } - // A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated - // seed wastes an agent call and can reach the mix twice through the seed fallback. - tracks, err := repo.GetAll(ctx, model.QueryOptions{ - Sort: "random", - Max: n * 4, - Filters: squirrel.Eq{"missing": false}, - }) - if err != nil { - return nil, err - } - mfs := dedupByID(tracks.MediaFiles()) - return mfs[:min(len(mfs), n)], nil -} - -func dedupByID(mfs model.MediaFiles) model.MediaFiles { - seen := make(map[string]struct{}, len(mfs)) - return slice.Filter(mfs, func(mf model.MediaFile) bool { - if _, dup := seen[mf.ID]; dup { - return false - } - seen[mf.ID] = struct{}{} - return true - }) -} - -func (e *provider) sampleAlbumTracks(ctx context.Context, albumID string, n int) (model.MediaFiles, error) { - return e.sampleTracks(ctx, squirrel.Eq{"album_id": albumID}, n) -} - -func (e *provider) sampleArtistTracks(ctx context.Context, artistID string, n int) (model.MediaFiles, error) { - // media_file.artist_id is the deprecated primary artist, so it misses an artist credited only - // on the album, as on compilations. Same filter the artist listings use. - filter := persistence.ParticipantIDFilter("media_file", artistID, model.RoleArtist, model.RoleAlbumArtist) - return e.sampleTracks(ctx, filter, n) -} - -func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n int) (model.MediaFiles, error) { - return e.sampleTracks(ctx, persistence.SongGenres.ByID(genre.ID), n) -} - -// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so -// missing files would surface as unplayable entries. -func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) { - return e.ds.MediaFile().GetRandom(ctx, model.QueryOptions{ - Filters: squirrel.And{filter, squirrel.Eq{"missing": false}}, - Max: n, - }) -} - -// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to -// get the artist of the given entity, retrieve similar artists, get their top songs, and pick -// a weighted random selection of songs to return as similar songs. -func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) { - artist, err := e.getArtist(ctx, id) - if err != nil { - return nil, err - } - - _ = e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) - return nil, ctx.Err() - } - - weightedSongs := random.NewWeightedChooser[model.MediaFile]() - addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error { - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) - return ctx.Err() - } - - topCount := max(count, 20) - topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount) - if err != nil { - log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err) - return nil - } - - weight := topCount * (4 + artistWeight) - for _, mf := range topSongs { - weightedSongs.Add(mf, weight) - weight -= 4 - } - return nil - } - - err = addArtist(artist.Artist, weightedSongs, count, 10) - if err != nil { - return nil, err - } - for _, a := range artist.SimilarArtists { - err := addArtist(a, weightedSongs, count, 0) - if err != nil { - return nil, err - } - } - - // Count distinct tracks, not picks: a collaboration sits in the chooser once per artist that - // lists it, and letting those repeats consume the budget strands unique candidates. - var similarSongs model.MediaFiles - picked := map[string]struct{}{} - for len(similarSongs) < count && weightedSongs.Size() > 0 { - s, err := weightedSongs.Pick() - if err != nil { - log.Warn(ctx, "Error getting weighted song", err) - continue - } - if _, dup := picked[s.ID]; dup { - continue - } - picked[s.ID] = struct{}{} - similarSongs = append(similarSongs, s) - } - - return similarSongs, nil -} diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index 3944edb68..c9a1a64ef 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -3,9 +3,6 @@ package external_test import ( "context" "errors" - "math" - "slices" - "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/agents" @@ -28,9 +25,6 @@ var _ = Describe("Provider - SimilarSongs", func() { var artistRepo *mockArtistRepo var mediaFileRepo *mockMediaFileRepo var albumRepo *mockAlbumRepo - var playlistRepo *tests.MockPlaylistRepo - var playlistTrackRepo *tests.MockPlaylistTrackRepo - var genreRepo *tests.MockedGenreRepo var ctx context.Context BeforeEach(func() { @@ -39,17 +33,11 @@ var _ = Describe("Provider - SimilarSongs", func() { artistRepo = newMockArtistRepo() mediaFileRepo = newMockMediaFileRepo() albumRepo = newMockAlbumRepo() - playlistTrackRepo = &tests.MockPlaylistTrackRepo{} - playlistRepo = tests.CreateMockPlaylistRepo() - playlistRepo.TracksRepo = playlistTrackRepo - genreRepo = &tests.MockedGenreRepo{} ds = &tests.MockDataStore{ MockedArtist: artistRepo, MockedMediaFile: mediaFileRepo, MockedAlbum: albumRepo, - MockedPlaylist: playlistRepo, - MockedGenre: genreRepo, } mockAgent = &mockSimilarArtistAgent{} @@ -61,54 +49,32 @@ var _ = Describe("Provider - SimilarSongs", func() { similarAgent: mockSimilarAgent, } - provider = NewProvider(ds, agentsCombined, matcher.New(ds), &fakeBroker{}) + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) }) - // Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive: - // no spec here asserts how many times the entity is looked up. - stubTrackEntity := func() { - track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"} - artist := model.Artist{ID: "artist-1", Name: "Artist"} - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Maybe() - artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() - } - Describe("dispatch by entity type", func() { Context("when ID is a MediaFile (track)", func() { It("calls GetSimilarSongsByTrack and returns matched songs", func() { track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"} - - // Depeche Mode artist row used by matcher artist resolution and track-fetch back-mapping. - dmArtist := model.Artist{ID: "dm-1", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid"} - dmParticipant := model.Participant{Artist: dmArtist} - matchedSong := model.MediaFile{ - ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{dmParticipant}}, - } + matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"} // GetEntityByID tries Artist, Album, Playlist, then MediaFile artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 1). + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5). Return([]agents.Song{ - {Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}}, + {Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"}, }, nil).Once() - // Matcher artist resolution: resolve Depeche Mode in the artist table. - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{dmArtist}, nil).Maybe() - - // ID phase: no IDs → squirrel.And with media_file.id; won't be called but guard it. + // Mock loadTracksByID - no ID matches mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { _, ok := opt.Filters.(squirrel.Eq) return ok - })).Return(model.MediaFiles{}, nil).Maybe() + })).Return(model.MediaFiles{}, nil).Once() - // MBID phase: won't fire (empty MBID). + // Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called) mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { and, ok := opt.Filters.(squirrel.And) if !ok || len(and) < 1 { @@ -122,22 +88,21 @@ var _ = Describe("Provider - SimilarSongs", func() { return hasMBID })).Return(model.MediaFiles{}, nil).Maybe() - // Matcher track-fetch: subquery returns the matched song with participants. + // Mock loadTracksByTitleAndArtist - queries by artist name mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { and, ok := opt.Filters.(squirrel.And) - if !ok { + if !ok || len(and) < 2 { return false } - for _, f := range and { - sql, _, err := f.ToSql() - if err == nil && strings.Contains(sql, "media_file_artists") { - return true - } + eq, hasEq := and[0].(squirrel.Eq) + if !hasEq { + return false } - return false + _, hasArtist := eq["order_artist_name"] + return hasArtist })).Return(model.MediaFiles{matchedSong}, nil).Maybe() - songs, err := provider.SimilarSongs(ctx, "track-1", 1) + songs, err := provider.SimilarSongs(ctx, "track-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -187,76 +152,6 @@ var _ = Describe("Provider - SimilarSongs", func() { Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("song-1")) }) - - It("tops the mix up with the fallback when the agent's picks alone are too few", func() { - stubTrackEntity() - - // The agent knows one track of the three asked for. - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3). - Return([]agents.Song{{Name: "Agent Pick", MBID: "mbid-agent"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything). - Return(model.MediaFiles{{ID: "agent-1", Title: "Agent Pick", MbzRecordingID: "mbid-agent"}}, nil).Once() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). - Return([]agents.Artist{}, nil).Once() - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). - Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything). - Return(model.MediaFiles{{ID: "song-1", Title: "Song One", MbzRecordingID: "mbid-1"}}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "track-1", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("agent-1", "song-1")) - }) - - It("reaches a unique pick that sits past the count-th repeat", func() { - stubTrackEntity() - mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Artist{}, nil).Maybe() - mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Song{}, nil).Maybe() - - // "Song B" sits past the second repeat, so a matcher capped at count never reaches it. - repeated := agents.Song{Name: "Song A", MBID: "mbid-a"} - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 2). - Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ - {ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"}, - {ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"}, - }, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "track-1", 2) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("t-a", "t-b")) - }) - - It("keeps topping up when the agent's picks repeat a track", func() { - stubTrackEntity() - - // The matcher re-emits a track when the same input song repeats, so these three - // picks resolve to only two distinct library tracks. - repeated := agents.Song{Name: "Song A", MBID: "mbid-a"} - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3). - Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ - {ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"}, - {ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"}, - }, nil).Once() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). - Return([]agents.Artist{}, nil).Once() - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). - Return([]agents.Song{{Name: "Song C", MBID: "mbid-c"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything). - Return(model.MediaFiles{{ID: "t-c", Title: "Song C", MbzRecordingID: "mbid-c"}}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "track-1", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) - }) }) Context("when ID is an Album", func() { @@ -268,9 +163,9 @@ var _ = Describe("Provider - SimilarSongs", func() { artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "album-1").Return(&album, nil).Once() - agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 1). + agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5). Return([]agents.Song{ - {Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, + {Name: "New Life", MBID: "song-mbid", Artist: "Depeche Mode"}, }, nil).Once() // Mock loadTracksByID - no ID matches @@ -289,75 +184,53 @@ var _ = Describe("Provider - SimilarSongs", func() { return hasEq })).Return(model.MediaFiles{matchedSong}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "album-1", 1) + songs, err := provider.SimilarSongs(ctx, "album-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("matched-1")) }) - It("falls back to sampled album tracks when GetSimilarSongsByAlbum returns ErrNotFound", func() { + It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() { album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"} - seed := model.MediaFile{ID: "seed-1", Title: "Seed", Artist: "Artist"} + artist := model.Artist{ID: "artist-1", Name: "Artist"} + song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + // GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "album-1").Return(&album, nil).Once() agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything). Return(nil, agents.ErrNotFound).Once() - mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { - sql, args, err := opt.Filters.ToSql() - return err == nil && strings.Contains(sql, "album_id") && - strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("album-1")) - })).Return(model.MediaFiles{seed}, nil).Once() + // Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album + // and recursively calls getArtist(v.AlbumArtistID) + artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "album-1").Return(&album, nil).Once() - // seedMix falls back to the seed itself when the agent finds nothing. - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "seed-1", "Seed", "Artist", "", mock.Anything). - Return([]agents.Song{}, nil).Once() + // Then it recurses with the artist-1 ID + artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist}, nil).Maybe() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). + Return([]agents.Artist{}, nil).Once() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 0 && opt.Filters != nil + })).Return(model.Artists{}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). + Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once() + + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once() songs, err := provider.SimilarSongs(ctx, "album-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("seed-1")) - }) - }) - - Context("when ID is an Album and the album agent returns nothing (AudioMuse-only)", func() { - It("samples the album's tracks and returns their track-similars", func() { - album := model.Album{ID: "al-1", Name: "The Album", AlbumArtist: "A"} - artistRepo.On("Get", "al-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "al-1").Return(&album, nil).Once() - - // AudioMuse doesn't implement album similarity -> empty. - agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-1", "The Album", "A", "", 5). - Return([]agents.Song{}, nil).Once() - - // sampleAlbumTracks -> GetRandom(album_id) -> one seed track - mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { - sql, args, err := opt.Filters.ToSql() - return err == nil && strings.Contains(sql, "album_id") && - strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("al-1")) - })).Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "A"}}, nil).Once() - - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "A", "", 5). - Return([]agents.Song{{Name: "AudioMuseResult", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once() - - // Matcher resolves "AudioMuseResult" -> a real MediaFile credited to artist "A". - aArtist := model.Artist{ID: "a1", Name: "A", OrderArtistName: "a"} - matchedTrack := model.MediaFile{ - ID: "m1", Title: "AudioMuseResult", Artist: "A", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{{Artist: aArtist}}}, - } - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{aArtist}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{matchedTrack}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "al-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - Expect(songs[0].ID).To(Equal("m1")) + Expect(songs[0].ID).To(Equal("song-1")) }) }) @@ -367,9 +240,9 @@ var _ = Describe("Provider - SimilarSongs", func() { matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"} artistRepo.On("Get", "artist-1").Return(&artist, nil).Once() - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 1). + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5). Return([]agents.Song{ - {Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, + {Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "Depeche Mode"}, }, nil).Once() // Mock loadTracksByID - no ID matches @@ -388,454 +261,13 @@ var _ = Describe("Provider - SimilarSongs", func() { return hasEq })).Return(model.MediaFiles{matchedSong}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "artist-1", 1) + songs, err := provider.SimilarSongs(ctx, "artist-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("matched-1")) }) }) - - Context("when ID is an Artist and both the artist agent and the similar-artists fallback are empty", func() { - It("samples the artist's tracks and returns their track-similars", func() { - artist := model.Artist{ID: "ar-1", Name: "The Artist"} - // Get is called twice: once to resolve the entity, once inside similarSongsFallback. - artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() - - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 5). - Return([]agents.Song{}, nil).Once() - // similarSongsFallback: no similar artists, no top songs -> empty (allow its lookups). - mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Artist{}, nil).Maybe() - mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Song{}, nil).Maybe() - - // Seeds come from the participant join covering both roles, so an artist credited - // only on the album (compilations, classical) still yields seeds. - mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { - sql, args, err := opt.Filters.ToSql() - return err == nil && strings.Contains(sql, "media_file_artists") && - strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("ar-1")) && - slices.Contains(args, any(model.RoleAlbumArtist.String())) && slices.Contains(args, any(model.RoleArtist.String())) - })).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5). - Return([]agents.Song{{Name: "Result"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Result"}}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "ar-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - }) - }) - - Context("when ID is an Artist and the similar-artists fallback can't fill the mix", func() { - It("tops the mix up with the artist's own track-similars", func() { - artist := model.Artist{ID: "ar-1", Name: "Thin Artist"} - topSong := model.MediaFile{ID: "top-1", Title: "Top Song", ArtistID: "ar-1", MbzRecordingID: "mbid-top"} - - artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() - - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "Thin Artist", "", 5). - Return([]agents.Song{}, nil).Once() - - // No similar artist is in the library, so the fallback yields only the seed artist's - // own matching top song: one track for a mix of five. - mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "Thin Artist", "", 15). - Return([]agents.Artist{}, nil).Once() - mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "Thin Artist", "", mock.Anything). - Return([]agents.Song{{Name: "Top Song", MBID: "mbid-top"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{topSong}, nil).Once() - - mediaFileRepo.On("GetRandom", mock.Anything). - Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "Thin Artist"}}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "Thin Artist", "", mock.Anything). - Return([]agents.Song{{Name: "Mix Song", MBID: "mbid-mix"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything). - Return(model.MediaFiles{{ID: "mix-1", Title: "Mix Song", MbzRecordingID: "mbid-mix"}}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "ar-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("top-1", "mix-1")) - }) - }) - - Context("when ID is an Artist and the agent plus the similar-artists fallback already fill the mix", func() { - It("does not pay for seed-track sampling", func() { - artist := model.Artist{ID: "ar-1", Name: "The Artist"} - artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() - - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 3). - Return([]agents.Song{{Name: "A", MBID: "mbid-a"}, {Name: "B", MBID: "mbid-b"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ - {ID: "t-a", Title: "A", MbzRecordingID: "mbid-a"}, - {ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"}, - }, nil).Once() - - // The similar-artists fallback supplies the third track, so the mix is full. - mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "The Artist", "", 15). - Return([]agents.Artist{}, nil).Once() - mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "The Artist", "", mock.Anything). - Return([]agents.Song{{Name: "C", MBID: "mbid-c"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything). - Return(model.MediaFiles{{ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}}, nil).Once() - - mediaFileRepo.On("GetRandom", mock.Anything).Return(model.MediaFiles{{ID: "seed"}}, nil).Maybe() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Song{}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "ar-1", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) - mediaFileRepo.AssertNotCalled(GinkgoT(), "GetRandom", mock.Anything) - }) - }) - - Context("when ID is a Playlist", func() { - It("samples playlist tracks and returns their track-similars", func() { - pls := model.Playlist{ID: "pl-1", Name: "My List"} - seedTrack := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"} - - // GetEntityByID order: Artist, Album, Playlist(hit) - artistRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - - // samplePlaylistTracks -> Tracks(...).GetAll -> one seed track, bounded+randomized in SQL - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: seedTrack}, - }) - - // seedMix -> GetSimilarSongsByTrack for the seed - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5). - Return([]agents.Song{{Name: "Similar", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once() - - // Matcher resolves "Similar" -> a real MediaFile (allow the matcher's lookups). - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{{ID: "a1", Name: "A"}}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "pl-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - }) - - It("asks for a smart-playlist refresh so an unevaluated one still yields seeds", func() { - // A smart playlist materializes no playlist_tracks until it is evaluated, so sampling - // without the refresh would mix an empty seed set. - pls := model.Playlist{ID: "pl-smart", Name: "Smart"} - artistRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - }) - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Song{}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "pl-smart", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - Expect(playlistRepo.TracksRefreshed).To(BeTrue()) - }) - - It("returns an error instead of panicking when the track repository is unavailable", func() { - // Tracks() logs and returns a nil repository when its own lookup fails. - pls := model.Playlist{ID: "pl-nil", Name: "Gone"} - artistRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistRepo.TracksRepo = nil - - _, err := provider.SimilarSongs(ctx, "pl-nil", 5) - - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("does not seed a mix with missing tracks", func() { - pls := model.Playlist{ID: "pl-missing", Name: "Missing"} - artistRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - }) - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Song{}, nil).Maybe() - - _, err := provider.SimilarSongs(ctx, "pl-missing", 5) - - Expect(err).ToNot(HaveOccurred()) - sql, args, sqlErr := playlistTrackRepo.Options.Filters.ToSql() - Expect(sqlErr).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("missing")) - Expect(args).To(ContainElement(false), "must exclude missing files, not select them") - }) - - It("keeps a later seed's picks when an earlier seed overlaps it", func() { - // The matcher stops once it has count matches, and it re-emits the shared track, so - // matching only count of the merged set would spend slots on the duplicate. - pls := model.Playlist{ID: "pl-overlap2", Name: "Overlap2"} - artistRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, - }) - shared := agents.Song{ID: "x", Name: "X"} - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3). - Return([]agents.Song{shared}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3). - Return([]agents.Song{shared, {ID: "y", Name: "Y"}, {ID: "z", Name: "Z"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ - {ID: "x", Title: "X"}, {ID: "y", Title: "Y"}, {ID: "z", Title: "Z"}, - }, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "pl-overlap2", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(3), "the duplicate must not cost a slot") - }) - - It("returns a track once when two seeds recommend it", func() { - // The matcher re-emits a track when two inputs are identical, so overlapping - // recommendations would otherwise take two slots in the mix. - pls := model.Playlist{ID: "pl-overlap", Name: "Overlap"} - artistRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, - }) - shared := agents.Song{ID: "m1", Name: "Shared"} - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5). - Return([]agents.Song{shared}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 5). - Return([]agents.Song{shared}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Shared"}}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "pl-overlap", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1), "the shared recommendation must appear once") - }) - - It("does not seed a mix twice with a track the playlist repeats", func() { - pls := model.Playlist{ID: "pl-dup", Name: "Dupes"} - artistRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - // The same file at two positions, which playlists allow. - dup := model.MediaFile{ID: "s1", Title: "Seed One"} - playlistTrackRepo.SetData(model.PlaylistTracks{{MediaFile: dup}, {MediaFile: dup}}) - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5). - Return([]agents.Song{}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "pl-dup", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1), "the repeated track must appear once") - agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 1) - }) - - It("clamps an enormous count before it reaches the queries", func() { - // count+1 in the local agent overflows on MaxInt64, and GetRandom omits the SQL - // limit unless Max is positive, so the query would hydrate the whole library. - pls := model.Playlist{ID: "pl-huge", Name: "Huge"} - artistRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - }) - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 500). - Return([]agents.Song{}, nil).Once() - - _, err := provider.SimilarSongs(ctx, "pl-huge", math.MaxInt64) - - Expect(err).ToNot(HaveOccurred()) - agentsCombined.AssertExpectations(GinkgoT()) - }) - - It("does not panic when the caller asks for a non-positive count", func() { - // Subsonic passes count straight through, so a negative one reaches the provider. - // The guard returns before any lookup, so no repository setup is needed. - songs, err := provider.SimilarSongs(ctx, "pl-neg", -1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - - It("blends results from every seed, not just the first", func() { - pls := model.Playlist{ID: "pl-blend", Name: "Blend"} - artistRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, - }) - - // Each seed returns a full count's worth, as a real similarity agent does. Asking for - // one more than seed one can supply makes this independent of the final shuffle: - // three of the four matches always include a seed-two track. - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3). - Return([]agents.Song{{ID: "a1", Name: "A1"}, {ID: "a2", Name: "A2"}}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3). - Return([]agents.Song{{ID: "b1", Name: "B1"}, {ID: "b2", Name: "B2"}}, nil).Once() - - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ - {ID: "a1", Title: "A1"}, {ID: "a2", Title: "A2"}, - {ID: "b1", Title: "B1"}, {ID: "b2", Title: "B2"}, - }, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "pl-blend", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(3)) - ids := ids(songs) - Expect(ids).To(ContainElement(BeElementOf("b1", "b2")), "seed two must be represented in the mix") - }) - - It("falls back to the seed tracks themselves when no similar songs are found", func() { - pls := model.Playlist{ID: "pl-2", Name: "Fallback List"} - seed1 := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"} - seed2 := model.MediaFile{ID: "s2", Title: "Seed Two", Artist: "B"} - - artistRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: seed1}, - {MediaFile: seed2}, - }) - - // Both seeds come back empty, so the mix must fall back to the seeds themselves. - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5). - Return([]agents.Song{}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "B", "", 5). - Return([]agents.Song{}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "pl-2", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(2)) - Expect([]string{songs[0].ID, songs[1].ID}).To(ConsistOf("s1", "s2")) - }) - - It("samples the album when the agent's picks are not in this library", func() { - // Last.fm answers from its own catalogue, so a small library can match none of it. - // An unmatched non-empty answer must not shortcut the sampling fallback. - album := model.Album{ID: "al-nm", Name: "NoMatch", AlbumArtist: "A"} - artistRepo.On("Get", "al-nm").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "al-nm").Return(&album, nil).Once() - agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-nm", "NoMatch", "A", "", 5). - Return([]agents.Song{{Name: "Not In Library"}}, nil).Once() - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe() - - // The matcher resolves nothing; the sampled seed is what reaches the mix. - mediaFileRepo.On("GetRandom", mock.Anything). - Return(model.MediaFiles{{ID: "s1", Title: "Album Track"}}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Album Track", "", "", 5). - Return([]agents.Song{}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "al-nm", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("s1")) - }) - - It("caps agent calls at maxSeeds when the repository ignores the bound", func() { - // Isolates seedMix's own cap: the album sampler bounds the query with Max, so this - // exercises the guard by having the repo hand back more rows than were asked for. - album := model.Album{ID: "al-cap", Name: "Cap", AlbumArtist: "A"} - artistRepo.On("Get", "al-cap").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "al-cap").Return(&album, nil).Once() - agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-cap", "Cap", "A", "", 5). - Return([]agents.Song{}, nil).Once() - - var overflow model.MediaFiles - for _, id := range []string{"t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"} { - overflow = append(overflow, model.MediaFile{ID: id, Title: id}) - } - mediaFileRepo.On("GetRandom", mock.Anything).Return(overflow, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5). - Return([]agents.Song{}, nil) - - _, err := provider.SimilarSongs(ctx, "al-cap", 5) - - Expect(err).ToNot(HaveOccurred()) - agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5) - }) - - It("still finds maxSeeds distinct seeds when the leading positions repeat", func() { - // The sampler over-fetches for exactly this case: a page bounded at maxSeeds could - // be entirely one repeated file and collapse to a single seed. - pls := model.Playlist{ID: "pl-3", Name: "Big List"} - dup := model.MediaFile{ID: "dup", Title: "Dup", Artist: "A"} - tracks := model.PlaylistTracks{ - {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, - } - for _, id := range []string{"seed-1", "seed-2", "seed-3", "seed-4", "seed-5"} { - tracks = append(tracks, model.PlaylistTrack{ - MediaFile: model.MediaFile{ID: id, Title: id, Artist: "A"}, - }) - } - - artistRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(tracks) - - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5). - Return([]agents.Song{}, nil) - - songs, err := provider.SimilarSongs(ctx, "pl-3", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(5)) - Expect(playlistTrackRepo.Options.Sort).To(Equal("random")) - agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5) - }) - }) - - Context("when ID is a Genre (not resolved by GetEntityByID)", func() { - It("samples genre songs and returns their track-similars", func() { - // GetEntityByID misses everywhere; the empty/auto-created mocks need no setup. - artistRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() - genreRepo.Data = map[string]model.Genre{"g-1": {ID: "g-1", Name: "Jazz"}} - - // sampleGenreTracks -> GetRandom with the indexed media_file_tags semi-join (not a json_tree scan) - mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { - if opt.Filters == nil { - return false - } - sql, args, err := opt.Filters.ToSql() - return err == nil && strings.Contains(sql, "media_file_tags") && - !strings.Contains(sql, "json_tree") && strings.Contains(sql, "missing") && slices.Contains(args, any(false)) - })).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5). - Return([]agents.Song{{Name: "Similar"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "g-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - }) - }) }) It("returns similar songs from main artist and similar artists", func() { @@ -897,60 +329,6 @@ var _ = Describe("Provider - SimilarSongs", func() { } }) - It("keeps picking until the fallback holds count distinct tracks", func() { - // A collaboration in two artists' top songs lands in the chooser twice. Picking a fixed - // count of entries lets those duplicates eat the budget and strand unique candidates. - track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist One", ArtistID: "artist-1"} - artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} - - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Twice() - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() - artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist1}, nil).Maybe() - - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist One", "", 3). - Return([]agents.Song{}, nil).Once() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). - Return([]agents.Artist{{Name: "Similar Artist"}}, nil).Once() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Eq) - return opt.Max == 0 && ok - })).Return(model.Artists{}, nil).Once() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Or) - return opt.Max == 0 && ok - })).Return(model.Artists{similarArtist}, nil).Once() - - shared := model.MediaFile{ID: "t-a", Title: "Shared", MbzRecordingID: "mbid-a"} - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return([]agents.Song{{Name: "Shared", MBID: "mbid-a"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")). - Return(model.MediaFiles{shared}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything). - Return([]agents.Song{ - {Name: "Shared", MBID: "mbid-a"}, - {Name: "B", MBID: "mbid-b"}, - {Name: "C", MBID: "mbid-c"}, - }, nil).Once() - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{ - shared, - {ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"}, - {ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}, - }, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "track-1", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) - }) - It("returns ErrNotFound when artist is not found", func() { artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) @@ -993,7 +371,7 @@ var _ = Describe("Provider - SimilarSongs", func() { mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "artist-1", 1) + songs, err := provider.SimilarSongs(ctx, "artist-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -1022,13 +400,6 @@ var _ = Describe("Provider - SimilarSongs", func() { mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return(nil, errors.New("error getting top songs")).Once() - // Fallback yields nothing, so the sampling path is tried and also finds no tracks. - mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { - sql, args, err := opt.Filters.ToSql() - return err == nil && strings.Contains(sql, "artist_id") && - strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("artist-1")) - })).Return(model.MediaFiles{}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "artist-1", 5) Expect(err).ToNot(HaveOccurred()) diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 5d35ba914..0d9b5800d 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -45,7 +45,7 @@ var _ = Describe("Provider - TopSongs", func() { ag = new(mockAgents) - p = NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) + p = NewProvider(ds, ag, matcher.New(ds)) }) It("returns top songs for a known artist", func() { @@ -65,7 +65,7 @@ var _ = Describe("Provider - TopSongs", func() { song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", "", 2) + songs, err := p.TopSongs(ctx, "Artist One", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -76,122 +76,11 @@ var _ = Describe("Provider - TopSongs", func() { mediaFileRepo.AssertExpectations(GinkgoT()) }) - Context("artist id and/or name", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - - BeforeEach(func() { - agentSongs := []agents.Song{{Name: "Song One", MBID: "mbid-song-1"}} - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - - 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() - }) - - It("returns top songs for a known artist by id only", func() { - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Once() - - songs, err := p.TopSongs(ctx, "", "artist-1", 2) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - - It("returns top songs for a known artist by id, falling back to name", func() { - artistRepo.On("Get", "fake-id").Return(nil, model.ErrNotFound).Once() - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "fake-id", 2) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - }) - - It("bails if lookup by id returns a fatal error", func() { - artistRepo.On("Get", "artist-1").Return(nil, model.ErrInvalidAuth).Once() - songs, err := p.TopSongs(ctx, "Artist One", "artist-1", 2) - Expect(songs).To(BeEmpty()) - Expect(err).To(HaveOccurred()) - }) - - It("bails if lookup by name returns a fatal error", func() { - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(nil, model.ErrInvalidAuth).Once() - songs, err := p.TopSongs(ctx, "Artist One", "", 2) - Expect(songs).To(BeEmpty()) - Expect(err).To(HaveOccurred()) - }) - - 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 artistRepo.On("Get", "fake-id").Return(nil, model.ErrNotFound).Once() - - // 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() - songs, err := p.TopSongs(ctx, "Unknown Artist", "", 5) + songs, err := p.TopSongs(ctx, "Unknown Artist", 5) Expect(err).ToNot(HaveOccurred()) // TopSongs returns nil error if artist not found Expect(songs).To(BeNil()) @@ -208,7 +97,7 @@ var _ = Describe("Provider - TopSongs", func() { agentErr := errors.New("agent error") ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agentErr).Once() - songs, err := p.TopSongs(ctx, "Artist One", "", 5) + songs, err := p.TopSongs(ctx, "Artist One", 5) Expect(err).To(MatchError(agentErr)) Expect(songs).To(BeNil()) @@ -224,7 +113,7 @@ var _ = Describe("Provider - TopSongs", func() { // Mock agent ErrNotFound ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agents.ErrNotFound).Once() - songs, err := p.TopSongs(ctx, "Artist One", "", 5) + songs, err := p.TopSongs(ctx, "Artist One", 5) Expect(err).To(MatchError(model.ErrNotFound)) Expect(songs).To(BeNil()) @@ -232,21 +121,6 @@ var _ = Describe("Provider - TopSongs", func() { ag.AssertExpectations(GinkgoT()) }) - // This endpoint answered with an empty list before retry-later existed; it must keep doing so. - It("returns an empty list, not a client error, when the agents are throttled", 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).Once() - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5). - Return(nil, agents.ErrRetryLater).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "", 5) - - Expect(songs).To(BeEmpty()) - Expect(err).To(MatchError(model.ErrNotFound), "the handler renders this as an empty 200") - Expect(err).ToNot(MatchError(agents.ErrRetryLater)) - ag.AssertExpectations(GinkgoT()) - }) - It("returns fewer songs if count is less than available top songs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} @@ -260,7 +134,7 @@ var _ = Describe("Provider - TopSongs", func() { 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() - songs, err := p.TopSongs(ctx, "Artist One", "", 1) + songs, err := p.TopSongs(ctx, "Artist One", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -274,8 +148,6 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - // Matcher artist resolution for the title-match path (song2 falls through). - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response agentSongs := []agents.Song{ @@ -287,9 +159,9 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding matching tracks (only find song 1 on bulk query) song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title track-fetch for song2: no match + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2 - songs, err := p.TopSongs(ctx, "Artist One", "", 2) + songs, err := p.TopSongs(ctx, "Artist One", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -311,7 +183,7 @@ var _ = Describe("Provider - TopSongs", func() { ag.On("GetArtistTopSongs", canceledCtx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, context.Canceled).Once() cancel() // Cancel the context before calling - songs, err := p.TopSongs(canceledCtx, "Artist One", "", 5) + songs, err := p.TopSongs(canceledCtx, "Artist One", 5) Expect(err).To(MatchError(context.Canceled)) Expect(songs).To(BeNil()) @@ -323,8 +195,6 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - // Matcher artist resolution for both title-fallback songs. - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response with songs that have NO MBID (empty string) agentSongs := []agents.Song{ @@ -333,19 +203,13 @@ var _ = Describe("Provider - TopSongs", func() { } ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - // Title track-fetch: tracks must carry RoleArtist participants so back-mapping routes them. - participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}} - song1 := model.MediaFile{ - ID: "song-1", Title: "Song One", Artist: "Artist One", ArtistID: "artist-1", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, - } - song2 := model.MediaFile{ - ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, - } + // Since there are no MBIDs, loadTracksByMBID should not make any database call + // loadTracksByTitle should make a database call for title matching + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", "", 2) + songs, err := p.TopSongs(ctx, "Artist One", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -360,8 +224,6 @@ var _ = Describe("Provider - TopSongs", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - // Matcher artist resolution for song2's title-fallback path. - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() // Mock agent response with mixed MBID availability agentSongs := []agents.Song{ @@ -374,15 +236,11 @@ var _ = Describe("Provider - TopSongs", func() { song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - // Title track-fetch: song2 must carry RoleArtist participants for back-mapping. - participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}} - song2 := model.MediaFile{ - ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, - } + // Mock the title fallback query (finds song2 by title) + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", "", 2) + songs, err := p.TopSongs(ctx, "Artist One", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -410,7 +268,7 @@ var _ = Describe("Provider - TopSongs", func() { song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", "", 1) + songs, err := p.TopSongs(ctx, "Artist One", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -438,7 +296,7 @@ var _ = Describe("Provider - TopSongs", func() { song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", "", 2) + songs, err := p.TopSongs(ctx, "Artist One", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -466,7 +324,7 @@ var _ = Describe("Provider - TopSongs", func() { 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() - songs, err := p.TopSongs(ctx, "Artist One", "", 1) + songs, err := p.TopSongs(ctx, "Artist One", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index d2fd4364e..3dd8a587a 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -34,8 +35,8 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) - mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) + p = external.NewProvider(ds, ag, matcher.New(ds)) + mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour }) @@ -89,7 +90,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ExternalUrl: "http://cached.com/album", Description: "Cached Desc", LargeImageUrl: "http://cached.com/large.jpg", - ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)), } mockAlbumRepo.SetData(model.Albums{*originalAlbum}) @@ -112,7 +113,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ExternalUrl: "http://expired.com/album", Description: "Expired Desc", LargeImageUrl: "http://expired.com/large.jpg", - ExternalInfoUpdatedAt: new(expiredTime), + ExternalInfoUpdatedAt: gg.P(expiredTime), } mockAlbumRepo.SetData(model.Albums{*originalAlbum}) @@ -164,26 +165,4 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ag.AssertExpectations(GinkgoT()) }) - - It("returns the original album, unstamped, when the agents are throttled", func() { - originalAlbum := &model.Album{ - ID: "al-throttled", - Name: "Throttled Album", - AlbumArtist: "Throttled Artist", - MbzAlbumID: "mbid-throttled", - } - mockAlbumRepo.SetData(model.Albums{*originalAlbum}) - - ag.On("GetAlbumInfo", ctx, "Throttled Album", "Throttled Artist", "mbid-throttled"). - Return(nil, agents.ErrRetryLater) - - updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-throttled") - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedAlbum).NotTo(BeNil()) - Expect(*updatedAlbum).To(Equal(*originalAlbum)) - Expect(updatedAlbum.ExternalInfoUpdatedAt).To(BeNil()) - - ag.AssertExpectations(GinkgoT()) - }) }) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index 5e2087d35..cc9506d1f 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -37,8 +38,8 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) - mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) + p = external.NewProvider(ds, ag, matcher.New(ds)) + mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) }) It("returns error when artist is not found", func() { @@ -104,25 +105,6 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ag.AssertExpectations(GinkgoT()) }) - // Stamping a throttled round would cache the empty result for the whole TTL. - It("does not stamp ExternalInfoUpdatedAt when the agents are throttled", func() { - originalArtist := &model.Artist{ID: "ar-throttled", Name: "Throttled Artist"} - mockArtistRepo.SetData(model.Artists{*originalArtist}) - - ag.On("GetArtistMBID", ctx, "ar-throttled", "Throttled Artist").Return("", agents.ErrRetryLater).Once() - ag.On("GetArtistImages", ctx, "ar-throttled", "Throttled Artist", "").Return(nil, agents.ErrRetryLater).Once() - ag.On("GetArtistBiography", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once() - ag.On("GetArtistURL", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once() - ag.On("GetSimilarArtists", ctx, "ar-throttled", "Throttled Artist", "", 100).Return(nil, agents.ErrRetryLater).Once() - - updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-throttled", 10, false) - - Expect(err).ToNot(HaveOccurred()) - Expect(updatedArtist).NotTo(BeNil()) - Expect(updatedArtist.ExternalInfoUpdatedAt).To(BeNil()) - ag.AssertExpectations(GinkgoT()) - }) - It("preserves decoded plain text in biography storage", func() { originalArtist := &model.Artist{ ID: "ar-encoded-bio", @@ -155,7 +137,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ExternalUrl: "http://cached.url", Biography: "Cached Bio", LargeImageUrl: "http://cached_large.jpg", - ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), SimilarArtists: model.Artists{ {ID: "ar-similar-present", Name: "Similar Present"}, {ID: "ar-similar-absent", Name: "Similar Absent"}, @@ -192,7 +174,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { originalArtist := &model.Artist{ ID: "ar-expired", Name: "Expired Artist", - ExternalInfoUpdatedAt: new(expiredTime), + ExternalInfoUpdatedAt: gg.P(expiredTime), SimilarArtists: model.Artists{ {ID: "ar-exp-similar", Name: "Expired Similar"}, }, @@ -223,7 +205,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { originalArtist := &model.Artist{ ID: "ar-similar-test", Name: "Similar Test Artist", - ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), SimilarArtists: model.Artists{ {ID: "ar-sim-present", Name: "Similar Present"}, {ID: "", Name: "Similar Absent Raw"}, diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index af59178af..80790c8d6 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -7,11 +7,9 @@ import ( "errors" "fmt" "io" - "io/fs" "os" "os/exec" "path/filepath" - "slices" "strconv" "strings" "sync" @@ -27,12 +25,11 @@ type TranscodeOptions struct { Command string // DB command template (used to detect custom vs default) Format string // Target format (mp3, opus, aac, flac) FilePath string - BitRate int // kbps, 0 = codec default - SampleRate int // 0 = no constraint - Channels int // 0 = no constraint - BitDepth int // 0 = no constraint; valid values: 16, 24, 32 - Offset int // seconds - Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header. + BitRate int // kbps, 0 = codec default + SampleRate int // 0 = no constraint + Channels int // 0 = no constraint + BitDepth int // 0 = no constraint; valid values: 16, 24, 32 + Offset int // seconds } // AudioProbeResult contains authoritative audio stream properties from ffprobe. @@ -49,6 +46,7 @@ type FFmpeg interface { Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) + Probe(ctx context.Context, files []string) (string, error) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) CmdPath() (string, error) IsAvailable() bool @@ -67,7 +65,8 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder const ( extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" - probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s" + probeCmd = "ffmpeg %s -f ffmetadata" + probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s" ) type ffmpeg struct{} @@ -85,11 +84,7 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC } else { args = buildTemplateArgs(opts) } - out, err := e.start(ctx, args) - if err != nil { - return nil, err - } - return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil + return e.start(ctx, args) } func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) { @@ -147,85 +142,32 @@ func fileExists(path string) error { return nil } +func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) { + if _, err := ffmpegCmd(); err != nil { + return "", err + } + args := createProbeCommand(probeCmd, files) + log.Trace(ctx, "Executing ffmpeg command", "args", args) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec + output, _ := cmd.CombinedOutput() + return string(output), nil +} + func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) { if _, err := ffmpegCmd(); err != nil { return nil, err } if err := fileExists(filePath); err != nil { - return nil, &ProbeError{Path: filePath, Reason: fileAccessReason(err), - NotFound: errors.Is(err, fs.ErrNotExist), err: err} + return nil, err } args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0) log.Trace(ctx, "Executing ffprobe command", "args", args) cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec output, err := cmd.Output() if err != nil { - return nil, &ProbeError{Path: filePath, Reason: probeClientReason(err, filePath), err: err} + return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err) } - result, err := parseProbeOutput(output) - if err != nil { - return nil, &ProbeError{Path: filePath, Reason: err.Error(), err: err} - } - return result, nil -} - -// ProbeError reports an ffprobe failure. Reason is a path-free message safe to -// expose to clients; the wrapped cause carries the full detail for logging. -// NotFound marks the media file itself as missing — a launch failure of a -// deleted ffprobe binary also wraps fs.ErrNotExist, so callers must not infer -// it from the error chain. -type ProbeError struct { - Path string - Reason string - NotFound bool - err error -} - -func (e *ProbeError) Error() string { - if e.err == nil { - return fmt.Sprintf("probe failed on %q: %s", e.Path, e.Reason) - } - return fmt.Sprintf("probe failed on %q: %s", e.Path, probeDetail(e.err)) -} - -// Unwrap exposes the underlying cause so callers can test it with errors.Is -// (e.g. fs.ErrNotExist to detect a missing file). -func (e *ProbeError) Unwrap() error { return e.err } - -// SafeReason returns the path-free reason, safe to send to clients. -func (e *ProbeError) SafeReason() string { return e.Reason } - -// fileAccessReason maps a stat failure to a clear, path-free reason, so a moved -// or unreadable file reads as "file not found" rather than a raw ffprobe message. -func fileAccessReason(err error) string { - switch { - case errors.Is(err, fs.ErrNotExist): - return "file not found" - case errors.Is(err, fs.ErrPermission): - return "permission denied" - default: - return "file not accessible" - } -} - -// probeDetail returns the full diagnostic for logging (may contain paths): -// ffprobe's stderr when present, otherwise the raw error text. -func probeDetail(err error) string { - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && len(exitErr.Stderr) > 0 { - return strings.TrimSpace(string(exitErr.Stderr)) - } - return err.Error() -} - -// probeClientReason returns a path-free reason for an ffprobe execution failure: -// ffprobe's stderr with the file path stripped, or a generic reason when ffprobe -// couldn't run at all (its launch error may embed the binary path). -func probeClientReason(err error, path string) string { - exitErr, ok := errors.AsType[*exec.ExitError](err) - if !ok || len(exitErr.Stderr) == 0 { - return "could not read file" - } - return strings.TrimSpace(strings.ReplaceAll(string(exitErr.Stderr), path, "the file")) + return parseProbeOutput(output) } type probeOutput struct { @@ -383,7 +325,8 @@ func (j *ffCmd) start(ctx context.Context) error { func (j *ffCmd) wait() { if err := j.cmd.Wait(); err != nil { - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()) if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" { errMsg += ": " + stderrOutput @@ -451,23 +394,14 @@ func isDefaultCommand(format, command string) bool { // including all transcoding parameters (bitrate, sample rate, channels). func buildDynamicArgs(opts TranscodeOptions) []string { cmdPath, _ := ffmpegCmd() - args := []string{cmdPath} + args := []string{cmdPath, "-i", opts.FilePath} if opts.Offset > 0 { args = append(args, "-ss", strconv.Itoa(opts.Offset)) } - args = append(args, "-i", opts.FilePath) args = append(args, "-map", "0:a:0") - // Preserve source tags. -map_metadata 0 copies format-level tags (MP3/FLAC); - // -map_metadata 0:s:a:0 copies tags from the first audio stream (OPUS/OGG). - // Both are needed because the two source families store tags at different - // levels. Targeting the audio stream explicitly (s:a:0 rather than s:0) avoids - // pulling metadata from an embedded cover-art/video stream at index 0. Note: - // adts (AAC) output cannot hold tags, so these are a no-op there. - args = append(args, "-map_metadata", "0", "-map_metadata", "0:s:a:0") - if codec, ok := formatCodecMap[opts.Format]; ok { args = append(args, "-c:a", codec) } @@ -557,20 +491,11 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { var args []string for _, s := range fixCmd(cmd) { if strings.Contains(s, "%s") { - if offset > 0 && !strings.Contains(cmd, "%t") { - // Pre-input seeking: ffmpeg seeks at the demuxer level (fast) - // instead of decoding all frames up to the offset (slow). - insertAt := len(args) - for i, arg := range slices.Backward(args) { - if arg == "-i" { - insertAt = i - break - } - } - args = slices.Insert(args, insertAt, "-ss", strconv.Itoa(offset)) - } s = strings.ReplaceAll(s, "%s", path) args = append(args, s) + if offset > 0 && !strings.Contains(cmd, "%t") { + args = append(args, "-ss", strconv.Itoa(offset)) + } } else { s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset)) s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate)) @@ -580,6 +505,20 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { return args } +func createProbeCommand(cmd string, inputs []string) []string { + var args []string + for _, s := range fixCmd(cmd) { + if s == "%s" { + for _, inp := range inputs { + args = append(args, "-i", inp) + } + } else { + args = append(args, s) + } + } + return args +} + func fixCmd(cmd string) []string { split := strings.Fields(cmd) cmdPath, _ := ffmpegCmd() diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 46684fe14..1649015d9 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -2,14 +2,12 @@ package ffmpeg import ( "context" - "errors" - "io" "os" "os/exec" "path/filepath" "runtime" "strings" - "sync" + sync "sync" "testing" "time" @@ -49,44 +47,51 @@ var _ = Describe("ffmpeg", func() { }) Context("when command has time offset param", func() { It("creates a valid command line with offset", func() { - args := createFFmpegCommand("ffmpeg -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) + args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456) + Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"})) }) }) Context("when command does not have time offset param", func() { - It("adds time offset before the input file name", func() { + It("adds time offset after the input file name", func() { args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) + Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"})) }) }) }) + Describe("createProbeCommand", func() { + It("creates a valid command line", func() { + args := createProbeCommand(probeCmd, []string{"/music library/one.mp3", "/music library/two.mp3"}) + Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"})) + }) + }) + When("ffmpegPath is set", func() { It("returns the correct ffmpeg path", func() { ffmpegPath = "/usr/bin/ffmpeg" - args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0) - Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "mp3", "-"})) + args := createProbeCommand(probeCmd, []string{"one.mp3"}) + Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "ffmetadata"})) }) It("returns the correct ffmpeg path with spaces", func() { ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe" - args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0) - Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "mp3", "-"})) + args := createProbeCommand(probeCmd, []string{"one.mp3"}) + Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"})) }) }) Describe("isDefaultCommand", func() { It("returns true for known default mp3 command", func() { - Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) + Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) }) It("returns true for known default opus command", func() { - Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) + Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) }) It("returns true for known default aac command", func() { - Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) + Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) }) It("returns true for known default flac command", func() { - Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) + Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) }) It("returns false for a custom command", func() { Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse()) @@ -108,7 +113,6 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libmp3lame", "-b:a", "256k", "-ar", "48000", @@ -128,7 +132,6 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.dsf", "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "flac", "-ar", "48000", "-v", "0", @@ -146,7 +149,6 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libopus", "-b:a", "128k", "-v", "0", @@ -163,11 +165,9 @@ var _ = Describe("ffmpeg", func() { Offset: 30, }) Expect(args).To(Equal([]string{ - "ffmpeg", + "ffmpeg", "-i", "/music/file.mp3", "-ss", "30", - "-i", "/music/file.mp3", "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "libmp3lame", "-b:a", "192k", "-v", "0", @@ -185,7 +185,6 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.flac", "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "aac", "-b:a", "256k", "-v", "0", @@ -203,7 +202,6 @@ var _ = Describe("ffmpeg", func() { Expect(args).To(Equal([]string{ "ffmpeg", "-i", "/music/file.dsf", "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", "-c:a", "flac", "-sample_fmt", "s32", "-v", "0", @@ -548,65 +546,6 @@ var _ = Describe("ffmpeg", func() { }) }) - Describe("ProbeError", func() { - It("uses the underlying cause in Error() so logs keep the full detail", func() { - e := &ProbeError{Path: "/music/foo.flac", - err: errors.New("/music/foo.flac: Invalid data found when processing input")} - Expect(e.Error()).To(ContainSubstring("/music/foo.flac")) - Expect(e.Error()).To(ContainSubstring("Invalid data found when processing input")) - }) - - It("returns the path-free reason from SafeReason()", func() { - e := &ProbeError{Path: "/music/foo.flac", Reason: "the file: Invalid data found when processing input"} - Expect(e.SafeReason()).To(Equal("the file: Invalid data found when processing input")) - Expect(e.SafeReason()).ToNot(ContainSubstring("/music/foo.flac")) - }) - - It("unwraps to the underlying cause so errors.Is detects a missing file", func() { - e := &ProbeError{Path: "/music/foo.flac", Reason: "file not found", err: os.ErrNotExist} - Expect(errors.Is(e, os.ErrNotExist)).To(BeTrue()) - }) - }) - - Describe("probeClientReason", func() { - It("strips the file path from ffprobe stderr", func() { - if runtime.GOOS == "windows" { - Skip("uses /bin/sh") - } - _, err := exec.Command("/bin/sh", "-c", "echo '/music/foo.flac: Invalid data found' >&2; exit 1").Output() - Expect(err).To(HaveOccurred()) - Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("the file: Invalid data found")) - }) - - It("returns a generic reason for launch failures, without leaking the binary path", func() { - err := errors.New("fork/exec /opt/navidrome/bin/ffprobe: no such file or directory") - Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("could not read file")) - }) - }) - - Describe("probeDetail", func() { - It("surfaces ffprobe stderr for logging", func() { - if runtime.GOOS == "windows" { - Skip("uses /bin/sh") - } - _, err := exec.Command("/bin/sh", "-c", "echo 'boom detail' >&2; exit 1").Output() - Expect(err).To(HaveOccurred()) - Expect(probeDetail(err)).To(Equal("boom detail")) - }) - }) - - Describe("fileAccessReason", func() { - It("reports a missing file as 'file not found', not a raw stat message", func() { - _, err := os.Stat("/no/such/dir/really-missing.flac") - Expect(err).To(HaveOccurred()) - Expect(fileAccessReason(err)).To(Equal("file not found")) - }) - - It("falls back to a generic reason for other access errors", func() { - Expect(fileAccessReason(errors.New("boom"))).To(Equal("file not accessible")) - }) - }) - Describe("FFmpeg", func() { Context("when FFmpeg is available", func() { var ff FFmpeg @@ -620,16 +559,6 @@ var _ = Describe("ffmpeg", func() { } }) - It("ProbeAudioStream returns a not-found ProbeError for a missing file", func() { - _, err := ff.ProbeAudioStream(GinkgoT().Context(), "/no/such/dir/really-missing.flac") - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue()) - var pe *ProbeError - Expect(errors.As(err, &pe)).To(BeTrue()) - Expect(pe.SafeReason()).To(Equal("file not found")) - Expect(pe.NotFound).To(BeTrue()) - }) - It("should interrupt transcoding when context is cancelled", func() { ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second) defer cancel() @@ -678,40 +607,6 @@ var _ = Describe("ffmpeg", func() { }) Expect(err).To(MatchError(context.Canceled)) }) - - It("fills in total_samples on a piped FLAC transcode", func() { - stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{ - Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", - Format: "flac", - FilePath: "tests/fixtures/test.flac", - Duration: 1, // the fixture is exactly 1s at 44100Hz - }) - Expect(err).ToNot(HaveOccurred()) - defer stream.Close() - - out, err := io.ReadAll(stream) - Expect(err).ToNot(HaveOccurred()) - Expect(string(out[:4])).To(Equal("fLaC")) - Expect(readTotalSamples(out)).To(Equal(uint64(44100))) - }) - - It("patches the duration net of the requested offset", func() { - // The command has no %t, so ffmpeg still emits the whole fixture. - // What is under test is the header arithmetic, not the audio. - stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{ - Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", - Format: "flac", - FilePath: "tests/fixtures/test.flac", - Duration: 3, - Offset: 1, - }) - Expect(err).ToNot(HaveOccurred()) - defer stream.Close() - - out, err := io.ReadAll(stream) - Expect(err).ToNot(HaveOccurred()) - Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100))) - }) }) Context("stderr capture", func() { diff --git a/core/ffmpeg/flac_streaminfo.go b/core/ffmpeg/flac_streaminfo.go deleted file mode 100644 index 878c28718..000000000 --- a/core/ffmpeg/flac_streaminfo.go +++ /dev/null @@ -1,66 +0,0 @@ -package ffmpeg - -import ( - "bytes" - "encoding/binary" - "errors" - "io" - "math" -) - -const ( - flacPrefixLen = 26 // through the last total_samples byte - flacMaxTotalSamples = 1<<36 - 1 -) - -// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0 -// when writing to a pipe, since a decoder cannot seek a cached FLAC without it. -func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser { - if duration <= 0 { - return r - } - return &flacPatcher{ReadCloser: r, duration: duration} -} - -type flacPatcher struct { - io.ReadCloser - duration float32 - // Peeking here rather than in the constructor keeps Transcode from blocking - // until ffmpeg has emitted its first bytes. - stream io.Reader -} - -func (f *flacPatcher) Read(p []byte) (int, error) { - if f.stream == nil { - prefix := make([]byte, flacPrefixLen) - n, err := io.ReadFull(f.ReadCloser, prefix) - if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { - return 0, err - } - prefix = prefix[:n] - if err == nil { - setFLACTotalSamples(prefix, f.duration) - } - f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser) - } - return f.stream.Read(p) -} - -// setFLACTotalSamples takes the rate from the header rather than the transcode -// options, so a resampled (-ar) output still gets the right count. -func setFLACTotalSamples(prefix []byte, duration float32) { - if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 { - return - } - // 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples - info := binary.BigEndian.Uint64(prefix[18:]) - rate := info >> 44 - if rate == 0 || info&flacMaxTotalSamples != 0 { - return - } - total := math.Round(float64(duration) * float64(rate)) - if total > flacMaxTotalSamples { - return - } - binary.BigEndian.PutUint64(prefix[18:], info|uint64(total)) -} diff --git a/core/ffmpeg/flac_streaminfo_test.go b/core/ffmpeg/flac_streaminfo_test.go deleted file mode 100644 index 6bf3503d7..000000000 --- a/core/ffmpeg/flac_streaminfo_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package ffmpeg - -import ( - "bytes" - "errors" - "io" - "os" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Decoded independently so the specs do not mirror the production bit-twiddling. -func readSampleRate(b []byte) int { - return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4 -} - -func readTotalSamples(b []byte) uint64 { - return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25]) -} - -var _ = Describe("patchFLACDuration", func() { - var fileFLAC []byte - - // Zeroing total_samples reproduces what a piped transcode emits. - pipedFLAC := func() []byte { - b := bytes.Clone(fileFLAC) - b[21] &= 0xF0 - clear(b[22:26]) - return b - } - - readAll := func(in []byte, duration float32) []byte { - out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration)) - Expect(err).ToNot(HaveOccurred()) - return out - } - - BeforeEach(func() { - var err error - fileFLAC, err = os.ReadFile("tests/fixtures/test.flac") - Expect(err).ToNot(HaveOccurred()) - Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate - }) - - It("fills in total_samples from the duration", func() { - out := readAll(pipedFLAC(), 1.0) - Expect(readTotalSamples(out)).To(Equal(uint64(44100))) - }) - - It("takes the sample rate from the header, not from the source file", func() { - in := pipedFLAC() - // Rewrite the header's rate to 48000, as -ar would. - in[18], in[19] = 0x0B, 0xB8 - in[20] &= 0x0F - - out := readAll(in, 2.0) - - Expect(readSampleRate(out)).To(Equal(48000)) - Expect(readTotalSamples(out)).To(Equal(uint64(96000))) - }) - - It("rounds to the nearest sample rather than truncating", func() { - // float32(0.7)*44100 is 30869.9995, so truncation would lose a sample. - out := readAll(pipedFLAC(), 0.7) - Expect(readTotalSamples(out)).To(Equal(uint64(30870))) - }) - - It("passes through when the duration overflows the 36-bit field", func() { - in := pipedFLAC() - Expect(readAll(in, 2e6)).To(Equal(in)) - }) - - It("leaves everything after the header untouched", func() { - in := pipedFLAC() - out := readAll(in, 1.0) - Expect(out).To(HaveLen(len(in))) - Expect(out[26:]).To(Equal(in[26:])) - Expect(out[:18]).To(Equal(in[:18])) - }) - - It("leaves an already-populated total_samples alone", func() { - out := readAll(fileFLAC, 99.0) - Expect(out).To(Equal(fileFLAC)) - }) - - It("passes through a stream that is not FLAC", func() { - in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes") - Expect(readAll(in, 1.0)).To(Equal(in)) - }) - - It("passes through when the first metadata block is not STREAMINFO", func() { - in := pipedFLAC() - in[4] = 0x04 // VORBIS_COMMENT - Expect(readAll(in, 1.0)).To(Equal(in)) - }) - - It("passes through a stream shorter than the STREAMINFO fields it patches", func() { - in := pipedFLAC()[:20] - Expect(readAll(in, 1.0)).To(Equal(in)) - }) - - It("passes through an empty stream", func() { - Expect(readAll(nil, 1.0)).To(BeEmpty()) - }) - - It("passes through when the duration is zero or negative", func() { - in := pipedFLAC() - Expect(readAll(in, 0)).To(Equal(in)) - Expect(readAll(in, -5)).To(Equal(in)) - }) - - It("passes through when the header declares no sample rate", func() { - in := pipedFLAC() - in[18], in[19] = 0, 0 - in[20] &= 0x0F - Expect(readAll(in, 1.0)).To(Equal(in)) - }) - - It("propagates a read error from the underlying stream", func() { - _, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader( - bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0)) - Expect(err).To(MatchError("boom")) - }) - - It("closes the underlying stream", func() { - c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())} - Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed()) - Expect(c.closed).To(BeTrue()) - }) -}) - -type errReader struct{} - -func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") } - -type closeSpy struct { - io.Reader - closed bool -} - -func (c *closeSpy) Close() error { c.closed = true; return nil } diff --git a/core/image_upload.go b/core/image_upload.go new file mode 100644 index 000000000..c2432b647 --- /dev/null +++ b/core/image_upload.go @@ -0,0 +1,71 @@ +package core + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" +) + +type ImageUploadService interface { + SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) + RemoveImage(ctx context.Context, path string) error +} + +type imageUploadService struct{} + +func NewImageUploadService() ImageUploadService { + return &imageUploadService{} +} + +func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { + filename := imageFilename(entityID, name, ext) + absPath := model.UploadedImagePath(entityType, filename) + + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + return "", fmt.Errorf("creating image directory: %w", err) + } + + // Remove old image if it exists + if oldPath != "" { + if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { + log.Warn(ctx, "Failed to remove old image", "path", oldPath, err) + } + } + + // Save new image + f, err := os.Create(absPath) + if err != nil { + return "", fmt.Errorf("creating image file: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, reader); err != nil { + return "", fmt.Errorf("writing image file: %w", err) + } + + return filename, nil +} + +func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error { + if path == "" { + return nil + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing image %q: %w", path, err) + } + return nil +} + +func imageFilename(id, name, ext string) string { + clean := utils.CleanFileName(name) + if clean == "" { + return id + ext + } + return id + "_" + clean + ext +} diff --git a/core/image_upload_test.go b/core/image_upload_test.go new file mode 100644 index 000000000..d13a04775 --- /dev/null +++ b/core/image_upload_test.go @@ -0,0 +1,99 @@ +package core_test + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ImageUploadService", func() { + var svc core.ImageUploadService + var tmpDir string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tmpDir = GinkgoT().TempDir() + conf.Server.DataFolder = tmpDir + svc = core.NewImageUploadService() + }) + + Describe("SetImage", func() { + It("creates directory and saves image file", func() { + ctx := context.Background() + reader := strings.NewReader("fake image data") + filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + Expect(filename).To(Equal("ar-1_pink_floyd.jpg")) + + absPath := filepath.Join(tmpDir, "artwork", "artist", "ar-1_pink_floyd.jpg") + data, err := os.ReadFile(absPath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("fake image data")) + }) + + It("falls back to ID-only filename when name cleans to empty", func() { + ctx := context.Background() + reader := strings.NewReader("data") + filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png") + Expect(err).ToNot(HaveOccurred()) + Expect(filename).To(Equal("pl-1.png")) + }) + + It("removes old image when replacing", func() { + ctx := context.Background() + oldDir := filepath.Join(tmpDir, "artwork", "artist") + Expect(os.MkdirAll(oldDir, 0755)).To(Succeed()) + oldFile := filepath.Join(oldDir, "ar-1_old.png") + Expect(os.WriteFile(oldFile, []byte("old"), 0600)).To(Succeed()) + + reader := strings.NewReader("new image") + _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "New Name", oldFile, reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + Expect(oldFile).ToNot(BeAnExistingFile()) + + newPath := filepath.Join(oldDir, "ar-1_new_name.jpg") + Expect(newPath).To(BeAnExistingFile()) + }) + + It("ignores missing old file without error", func() { + ctx := context.Background() + reader := strings.NewReader("data") + _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("RemoveImage", func() { + It("removes the file at the given path", func() { + ctx := context.Background() + dir := filepath.Join(tmpDir, "artwork", "artist") + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + path := filepath.Join(dir, "ar-1_test.jpg") + Expect(os.WriteFile(path, []byte("img"), 0600)).To(Succeed()) + + err := svc.RemoveImage(ctx, path) + Expect(err).ToNot(HaveOccurred()) + Expect(path).ToNot(BeAnExistingFile()) + }) + + It("succeeds when file does not exist", func() { + ctx := context.Background() + err := svc.RemoveImage(ctx, "/nonexistent/file.jpg") + Expect(err).ToNot(HaveOccurred()) + }) + + It("succeeds with empty path", func() { + ctx := context.Background() + err := svc.RemoveImage(ctx, "") + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/core/inspect.go b/core/inspect.go index c60459b88..751cf063f 100644 --- a/core/inspect.go +++ b/core/inspect.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" + . "github.com/navidrome/navidrome/utils/gg" ) type InspectOutput struct { @@ -15,7 +16,7 @@ type InspectOutput struct { MappedTags *model.MediaFile `json:"mappedTags,omitempty"` } -func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) { +func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) { path, file := filepath.Split(filePath) s, err := storage.For(path) @@ -39,22 +40,12 @@ func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutpu return nil, model.ErrNotFound } - md := metadata.New(scannerPath(lib, filePath), tag) + md := metadata.New(path, tag) result := &InspectOutput{ File: filePath, RawTags: tags[file].Tags, - MappedTags: new(md.ToMediaFile(lib, folderId)), + MappedTags: P(md.ToMediaFile(libraryId, folderId)), } return result, nil } - -// scannerPath returns the path the scanner uses for the file (relative to its library), so -// folder-based PIDs match the DB. Files outside the library keep their absolute path. -func scannerPath(lib model.Library, filePath string) string { - absPath, err := filepath.Abs(filePath) - if err != nil || lib.Path == "" { - return filePath - } - return model.LibraryRelativePath(lib.Path, absPath) -} diff --git a/core/inspect_test.go b/core/inspect_test.go deleted file mode 100644 index 0ac90990c..000000000 --- a/core/inspect_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package core_test - -import ( - "path/filepath" - - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Inspect", func() { - var fixtures string - - BeforeEach(func() { - var err error - fixtures, err = filepath.Abs(filepath.Join("tests", "fixtures")) - Expect(err).ToNot(HaveOccurred()) - }) - - It("maps the file with the library-relative path the scanner uses", func() { - lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"} - out, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "") - Expect(err).ToNot(HaveOccurred()) - Expect(out.MappedTags.Path).To(Equal("fixtures/test.mp3")) - Expect(out.MappedTags.LibraryID).To(Equal(2)) - }) - - It("gives the same IDs for relative and absolute paths", func() { - lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"} - abs, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "") - Expect(err).ToNot(HaveOccurred()) - rel, err := core.Inspect(filepath.Join("tests", "fixtures", "test.mp3"), lib, "") - Expect(err).ToNot(HaveOccurred()) - Expect(rel.MappedTags.AlbumID).To(Equal(abs.MappedTags.AlbumID)) - Expect(rel.MappedTags.PID).To(Equal(abs.MappedTags.PID)) - }) - - It("keeps the given path for a file outside the library", func() { - filePath := filepath.Join(fixtures, "test.mp3") - out, err := core.Inspect(filePath, model.Library{ID: model.DefaultLibraryID}, "") - Expect(err).ToNot(HaveOccurred()) - Expect(out.MappedTags.Path).To(Equal(filePath)) - }) -}) diff --git a/core/library.go b/core/library.go index f1153da26..0bf3be9fa 100644 --- a/core/library.go +++ b/core/library.go @@ -7,7 +7,6 @@ import ( "io/fs" "os" "path/filepath" - "slices" "strconv" "strings" "time" @@ -17,7 +16,6 @@ import ( "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/slice" @@ -35,28 +33,25 @@ type Library interface { SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error - Repository() rest.Repository[model.Library] + NewRepository(ctx context.Context) rest.Repository } type libraryService struct { - ds model.DataStore - broker events.Broker - repo *libraryRepositoryWrapper + ds model.DataStore + scanner model.Scanner + watcher Watcher + broker events.Broker + pluginManager PluginUnloader } // NewLibrary creates a new Library service func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library { return &libraryService{ - ds: ds, - broker: broker, - repo: &libraryRepositoryWrapper{ - LibraryRepository: ds.Library(), - ds: ds, - scanner: scanner, - watcher: watcher, - broker: broker, - pluginManager: pluginManager, - }, + ds: ds, + scanner: scanner, + watcher: watcher, + broker: broker, + pluginManager: pluginManager, } } @@ -64,16 +59,16 @@ func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, brok func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) { // Verify user exists - if _, err := s.ds.User().Get(ctx, userID); err != nil { + if _, err := s.ds.User(ctx).Get(userID); err != nil { return nil, err } - return s.ds.User().GetUserLibraries(ctx, userID) + return s.ds.User(ctx).GetUserLibraries(userID) } func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error { // Verify user exists - user, err := s.ds.User().Get(ctx, userID) + user, err := s.ds.User(ctx).Get(userID) if err != nil { return err } @@ -96,7 +91,7 @@ func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, li } // Set user libraries - err = s.ds.User().SetUserLibraries(ctx, userID, libraryIDs) + err = s.ds.User(ctx).SetUserLibraries(userID, libraryIDs) if err != nil { return fmt.Errorf("error setting user libraries: %w", err) } @@ -121,7 +116,7 @@ func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID strin } // Check if user has explicit access to this library - libraries, err := s.ds.User().GetUserLibraries(ctx, userID) + libraries, err := s.ds.User(ctx).GetUserLibraries(userID) if err != nil { log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err) return fmt.Errorf("error checking library access: %w", err) @@ -138,14 +133,25 @@ func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID strin // REST repository wrapper -func (s *libraryService) Repository() rest.Repository[model.Library] { - return s.repo +func (s *libraryService) NewRepository(ctx context.Context) rest.Repository { + repo := s.ds.Library(ctx) + wrapper := &libraryRepositoryWrapper{ + ctx: ctx, + LibraryRepository: repo, + Repository: repo.(rest.Repository), + ds: s.ds, + scanner: s.scanner, + watcher: s.watcher, + broker: s.broker, + pluginManager: s.pluginManager, + } + return wrapper } -var _ rest.Persistable[model.Library] = (*libraryRepositoryWrapper)(nil) - type libraryRepositoryWrapper struct { + rest.Repository model.LibraryRepository + ctx context.Context ds model.DataStore scanner model.Scanner watcher Watcher @@ -153,94 +159,87 @@ type libraryRepositoryWrapper struct { pluginManager PluginUnloader } -func (r *libraryRepositoryWrapper) Save(ctx context.Context, lib *model.Library) (string, error) { - if err := r.validateLibrary(ctx, lib); err != nil { +func (r *libraryRepositoryWrapper) Save(entity any) (string, error) { + lib := entity.(*model.Library) + if err := r.validateLibrary(lib); err != nil { return "", err } - err := r.LibraryRepository.Put(ctx, lib) + err := r.LibraryRepository.Put(lib) if err != nil { return "", r.mapError(err) } // Start watcher and trigger scan after successful library creation if r.watcher != nil { - if err := r.watcher.Watch(ctx, lib); err != nil { - log.Warn(ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) + if err := r.watcher.Watch(r.ctx, lib); err != nil { + log.Warn(r.ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) } } if r.scanner != nil { - go r.triggerScan(ctx, lib, "new") + go r.triggerScan(lib, "new") } // Send library refresh event to all clients if r.broker != nil { event := &events.RefreshResource{} - r.broker.SendBroadcastMessage(ctx, event.With("library", strconv.Itoa(lib.ID))) - log.Debug(ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name) + r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID))) + log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name) } return strconv.Itoa(lib.ID), nil } -func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity model.Library, cols ...string) error { - lib := &entity +func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error { + lib := entity.(*model.Library) libID, err := strconv.Atoi(id) if err != nil { return fmt.Errorf("invalid library ID: %s", id) } lib.ID = libID - if err := r.validateLibrary(ctx, lib); err != nil { + if err := r.validateLibrary(lib); err != nil { return err } // Get the original library to check if path changed - originalLib, err := r.Get(ctx, libID) + originalLib, err := r.Get(libID) if err != nil { return r.mapError(err) } pathChanged := originalLib.Path != lib.Path - pidChanged := (updatesColumn(cols, "pidAlbum") && originalLib.PIDAlbum != lib.PIDAlbum) || - (updatesColumn(cols, "pidTrack") && originalLib.PIDTrack != lib.PIDTrack) - err = r.LibraryRepository.Put(ctx, lib, cols...) + err = r.LibraryRepository.Put(lib) if err != nil { return r.mapError(err) } - if pathChanged && r.watcher != nil { - if err := r.watcher.Watch(ctx, lib); err != nil { - log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) + // Restart watcher and trigger scan if path was updated + if pathChanged { + if r.watcher != nil { + if err := r.watcher.Watch(r.ctx, lib); err != nil { + log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) + } } - } - if (pathChanged || pidChanged) && r.scanner != nil { - go r.triggerScan(ctx, lib, "updated") + if r.scanner != nil { + go r.triggerScan(lib, "updated") + } } // Send library refresh event to all clients if r.broker != nil { event := &events.RefreshResource{} - r.broker.SendBroadcastMessage(ctx, event.With("library", id)) - log.Debug(ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name) + r.broker.SendBroadcastMessage(r.ctx, event.With("library", id)) + log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name) } return nil } -func (r *libraryRepositoryWrapper) Delete(ctx context.Context, ids ...string) error { - for _, id := range ids { - if err := r.deleteOne(ctx, id); err != nil { - return err - } - } - return nil -} - -func (r *libraryRepositoryWrapper) deleteOne(ctx context.Context, id string) error { +func (r *libraryRepositoryWrapper) Delete(id string) error { libID, err := strconv.Atoi(id) if err != nil { return &rest.ValidationError{Errors: map[string]string{ @@ -249,41 +248,37 @@ func (r *libraryRepositoryWrapper) deleteOne(ctx context.Context, id string) err } // Get library info before deletion for logging - lib, err := r.Get(ctx, libID) + lib, err := r.Get(libID) if err != nil { return r.mapError(err) } - // Run the deletion in a transaction so the cascade delete and the orphaned-artist - // reconciliation it triggers (see libraryRepository.Delete) commit atomically. - err = r.ds.WithTx(func(tx model.DataStore) error { - return tx.Library().Delete(ctx, libID) - }, "delete library") + err = r.LibraryRepository.Delete(libID) if err != nil { return r.mapError(err) } // Stop watcher and trigger scan after successful library deletion to clean up orphaned data if r.watcher != nil { - if err := r.watcher.StopWatching(ctx, libID); err != nil { - log.Warn(ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err) + if err := r.watcher.StopWatching(r.ctx, libID); err != nil { + log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err) } } if r.scanner != nil { - go r.triggerScan(ctx, lib, "deleted") + go r.triggerScan(lib, "deleted") } // Send library refresh event to all clients if r.broker != nil { event := &events.RefreshResource{} - r.broker.SendBroadcastMessage(ctx, event.With("library", id)) - log.Debug(ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name) + r.broker.SendBroadcastMessage(r.ctx, event.With("library", id)) + log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name) } // After successful deletion, check if any plugins were auto-disabled // and need to be unloaded from memory - r.pluginManager.UnloadDisabledPlugins(ctx) + r.pluginManager.UnloadDisabledPlugins(r.ctx) return nil } @@ -308,10 +303,17 @@ func (r *libraryRepositoryWrapper) mapError(err error) error { } } - return err + switch { + case errors.Is(err, model.ErrNotFound): + return rest.ErrNotFound + case errors.Is(err, model.ErrNotAuthorized): + return rest.ErrPermissionDenied + default: + return err + } } -func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library *model.Library) error { +func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error { validationErrors := make(map[string]string) if library.Name == "" { @@ -322,20 +324,11 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library validationErrors["path"] = "ra.validation.required" } else { // Validate path format and accessibility - if err := r.validateLibraryPath(ctx, library); err != nil { + if err := r.validateLibraryPath(library); err != nil { validationErrors["path"] = err.Error() } } - library.PIDAlbum = strings.TrimSpace(library.PIDAlbum) - library.PIDTrack = strings.TrimSpace(library.PIDTrack) - if err := metadata.ValidatePIDSpec(library.PIDAlbum, true); err != nil { - validationErrors["pidAlbum"] = err.Error() - } - if err := metadata.ValidatePIDSpec(library.PIDTrack, false); err != nil { - validationErrors["pidTrack"] = err.Error() - } - if len(validationErrors) > 0 { return &rest.ValidationError{Errors: validationErrors} } @@ -343,12 +336,7 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library return nil } -// updatesColumn reports whether an update with these columns writes col. No columns means all of them. -func updatesColumn(cols []string, col string) bool { - return len(cols) == 0 || slices.Contains(cols, col) -} - -func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error { +func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) error { // Validate path format if !filepath.IsAbs(library.Path) { return fmt.Errorf("library path must be absolute") @@ -366,7 +354,7 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, libr fsys, err := fileStore.FS() if err != nil { - log.Warn(ctx, "Error validating library.path", "path", library.Path, err) + log.Warn(r.ctx, "Error validating library.path", "path", library.Path, err) return fmt.Errorf("resources.library.validation.pathInvalid") } @@ -374,7 +362,7 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, libr info, err := fs.Stat(fsys, ".") if err != nil { // Parse the error message to check for "not a directory" - log.Warn(ctx, "Error stating library.path", "path", library.Path, err) + log.Warn(r.ctx, "Error stating library.path", "path", library.Path, err) errStr := err.Error() if strings.Contains(errStr, "not a directory") || strings.Contains(errStr, "The directory name is invalid.") { @@ -401,7 +389,7 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in } // Use CountAll to efficiently validate library IDs exist - count, err := s.ds.Library().CountAll(ctx, model.QueryOptions{ + count, err := s.ds.Library(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"id": libraryIDs}, }) if err != nil { @@ -415,29 +403,13 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in return nil } -var scanWaitInterval = time.Second - -func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) { - // Runs in its own goroutine and outlives the HTTP request - ctx = context.WithoutCancel(ctx) - - // A running scan loaded the libraries before this change, and would reject a new request - for { - status, err := r.scanner.Status(ctx) - if err != nil || !status.Scanning { - break - } - time.Sleep(scanWaitInterval) - } - - log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) +func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) { + log.Info(r.ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) start := time.Now() - warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full - if errors.Is(err, model.ErrAlreadyScanning) { - log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name) - } else if err != nil { - log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) + warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library + if err != nil { + log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) } else { - log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) + log.Info(r.ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) } } diff --git a/core/library_test.go b/core/library_test.go index e6ebb1974..175d9c37d 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -66,18 +66,18 @@ var _ = Describe("Library Service", func() { }) Describe("Library CRUD Operations", func() { - var repo rest.Persistable[model.Library] + var repo rest.Persistable BeforeEach(func() { - r := service.Repository() - repo = r.(rest.Persistable[model.Library]) + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) }) Describe("Create", func() { It("creates a new library successfully", func() { library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).NotTo(HaveOccurred()) Expect(libraryRepo.Data[1].Name).To(Equal("New Library")) @@ -87,7 +87,7 @@ var _ = Describe("Library Service", func() { It("fails when library name is empty", func() { library := &model.Library{Path: tempDir} - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ra.validation.required")) @@ -96,7 +96,7 @@ var _ = Describe("Library Service", func() { It("fails when library path is empty", func() { library := &model.Library{Name: "Test"} - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ra.validation.required")) @@ -105,7 +105,7 @@ var _ = Describe("Library Service", func() { It("fails when library path is not absolute", func() { library := &model.Library{Name: "Test", Path: "relative/path"} - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -140,7 +140,7 @@ var _ = Describe("Library Service", func() { return errors.New("UNIQUE constraint failed: library.name") } - _, err = repo.Save(ctx, library) + _, err = repo.Save(library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -157,7 +157,7 @@ var _ = Describe("Library Service", func() { return errors.New("UNIQUE constraint failed: library.path") } - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -181,22 +181,13 @@ var _ = Describe("Library Service", func() { library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} - err = repo.Update(ctx, "1", *library) + err = repo.Update("1", library) Expect(err).NotTo(HaveOccurred()) Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library")) Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir)) }) - It("forwards the columns sent by the client to the repository", func() { - library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} - - err := repo.Update(ctx, "1", *library, "name", "path") - - Expect(err).NotTo(HaveOccurred()) - Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"})) - }) - It("fails when library doesn't exist", func() { // Create a unique temporary directory to avoid path conflicts uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-") @@ -205,7 +196,7 @@ var _ = Describe("Library Service", func() { library := &model.Library{ID: 999, Name: "Non-existent", Path: uniqueTempDir} - err = repo.Update(ctx, "999", *library) + err = repo.Update("999", library) Expect(err).To(HaveOccurred()) Expect(err).To(Equal(model.ErrNotFound)) @@ -214,7 +205,7 @@ var _ = Describe("Library Service", func() { It("fails when library name is empty", func() { library := &model.Library{ID: 1, Path: tempDir} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ra.validation.required")) @@ -224,7 +215,7 @@ var _ = Describe("Library Service", func() { unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir) library := &model.Library{ID: 1, Name: "Updated Library", Path: unnormalizedPath} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).NotTo(HaveOccurred()) Expect(libraryRepo.Data[1].Path).To(Equal(filepath.Clean(unnormalizedPath))) @@ -239,7 +230,7 @@ var _ = Describe("Library Service", func() { // Update the library keeping the same name (should be allowed) library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).NotTo(HaveOccurred()) }) @@ -253,7 +244,7 @@ var _ = Describe("Library Service", func() { // Update the library keeping the same path (should be allowed) library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).NotTo(HaveOccurred()) }) @@ -284,7 +275,7 @@ var _ = Describe("Library Service", func() { // Try to update library 2 to have the same name as library 1 library := &model.Library{ID: 2, Name: "Library One", Path: otherTempDir} - err = repo.Update(ctx, "2", *library) + err = repo.Update("2", library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -312,7 +303,7 @@ var _ = Describe("Library Service", func() { // Try to update library 2 to have the same path as library 1 library := &model.Library{ID: 2, Name: "Library Two", Path: tempDir} - err = repo.Update(ctx, "2", *library) + err = repo.Update("2", library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -322,43 +313,12 @@ var _ = Describe("Library Service", func() { }) }) - Describe("PID validation", func() { - pidError := func(err error, field string) string { - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - return validationErr.Errors[field] - } - - It("rejects an unknown attribute in the album PID", func() { - _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"}) - Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`)) - }) - - It("rejects albumid in the album PID", func() { - _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"}) - Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid")) - }) - - It("rejects an unknown attribute in the track PID", func() { - _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"}) - Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`)) - }) - - It("trims spaces", func() { - library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: " "} - _, err := repo.Save(ctx, library) - Expect(err).ToNot(HaveOccurred()) - Expect(library.PIDAlbum).To(Equal("folder")) - Expect(library.PIDTrack).To(BeEmpty()) - }) - }) - Describe("Path Validation", func() { Context("Create operation", func() { It("fails when path is not absolute", func() { library := &model.Library{Name: "Test", Path: "relative/path"} - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -370,7 +330,7 @@ var _ = Describe("Library Service", func() { nonExistentPath := filepath.Join(tempDir, "nonexistent") library := &model.Library{Name: "Test", Path: nonExistentPath} - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -385,7 +345,7 @@ var _ = Describe("Library Service", func() { library := &model.Library{Name: "Test", Path: testFile} - _, err = repo.Save(ctx, library) + _, err = repo.Save(library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -402,7 +362,7 @@ var _ = Describe("Library Service", func() { It("handles multiple validation errors", func() { library := &model.Library{Name: "", Path: "relative/path"} - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -424,7 +384,7 @@ var _ = Describe("Library Service", func() { It("fails when updated path is not absolute", func() { library := &model.Library{ID: 1, Name: "Test", Path: "relative/path"} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -441,7 +401,7 @@ var _ = Describe("Library Service", func() { // Update the library keeping the same name (should be allowed) library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).NotTo(HaveOccurred()) }) @@ -450,7 +410,7 @@ var _ = Describe("Library Service", func() { nonExistentPath := filepath.Join(tempDir, "nonexistent") library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -465,7 +425,7 @@ var _ = Describe("Library Service", func() { library := &model.Library{ID: 1, Name: "Test", Path: testFile} - err = repo.Update(ctx, "1", *library) + err = repo.Update("1", library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -477,7 +437,7 @@ var _ = Describe("Library Service", func() { // Try to update with empty name and invalid path library := &model.Library{ID: 1, Name: "", Path: "relative/path"} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).To(HaveOccurred()) var validationErr *rest.ValidationError @@ -498,14 +458,14 @@ var _ = Describe("Library Service", func() { }) It("deletes an existing library successfully", func() { - err := repo.Delete(ctx, "1") + err := repo.Delete("1") Expect(err).NotTo(HaveOccurred()) Expect(libraryRepo.Data).To(HaveLen(0)) }) It("fails when library doesn't exist", func() { - err := repo.Delete(ctx, "999") + err := repo.Delete("999") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(model.ErrNotFound)) @@ -644,17 +604,17 @@ var _ = Describe("Library Service", func() { }) Describe("Scan Triggering", func() { - var repo rest.Persistable[model.Library] + var repo rest.Persistable BeforeEach(func() { - r := service.Repository() - repo = r.(rest.Persistable[model.Library]) + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) }) It("triggers scan when creating a new library", func() { library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).NotTo(HaveOccurred()) // Wait briefly for the goroutine to complete @@ -680,7 +640,7 @@ var _ = Describe("Library Service", func() { // Update the library with a new path library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} - err = repo.Update(ctx, "1", *library) + err = repo.Update("1", library) Expect(err).NotTo(HaveOccurred()) // Wait briefly for the goroutine to complete @@ -701,7 +661,7 @@ var _ = Describe("Library Service", func() { // Update the library name only (same path) library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).NotTo(HaveOccurred()) // Wait a bit to ensure no scan was triggered @@ -710,53 +670,11 @@ var _ = Describe("Library Service", func() { }, "100ms", "10ms").Should(Equal(0)) }) - It("triggers scan when updating the library PID config", func() { - libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}}) - - library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"} - Expect(repo.Update(ctx, "1", library)).To(Succeed()) - - Eventually(func() int { - return scanner.GetScanAllCallCount() - }, "1s", "10ms").Should(Equal(1)) - // A quick scan: the scanner itself rescans this library in full - Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse()) - }) - - It("does not trigger scan when the PID fields were not sent", func() { - libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}}) - - // The REST layer decodes a missing pidAlbum as "". Only the sent fields count. - library := model.Library{ID: 1, Name: "Renamed", Path: tempDir} - Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed()) - - Consistently(func() int { - return scanner.GetScanAllCallCount() - }, "100ms", "10ms").Should(Equal(0)) - }) - - It("waits for a running scan before triggering a new one", func() { - libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}}) - scanner.SetScanning(true) - - library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"} - Expect(repo.Update(ctx, "1", library)).To(Succeed()) - - Consistently(func() int { - return scanner.GetScanAllCallCount() - }, "200ms", "20ms").Should(Equal(0)) - - scanner.SetScanning(false) - Eventually(func() int { - return scanner.GetScanAllCallCount() - }, "3s", "20ms").Should(Equal(1)) - }) - It("does not trigger scan when library creation fails", func() { // Try to create library with invalid data (empty name) library := &model.Library{Path: tempDir} - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).To(HaveOccurred()) // Ensure no scan was triggered since creation failed @@ -773,7 +691,7 @@ var _ = Describe("Library Service", func() { // Try to update with invalid data (empty name) library := &model.Library{ID: 1, Name: "", Path: tempDir} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).To(HaveOccurred()) // Ensure no scan was triggered since update failed @@ -789,7 +707,7 @@ var _ = Describe("Library Service", func() { }) // Delete the library - err := repo.Delete(ctx, "1") + err := repo.Delete("1") Expect(err).NotTo(HaveOccurred()) // Wait briefly for the goroutine to complete @@ -804,7 +722,7 @@ var _ = Describe("Library Service", func() { It("does not trigger scan when library deletion fails", func() { // Try to delete a non-existent library - err := repo.Delete(ctx, "999") + err := repo.Delete("999") Expect(err).To(HaveOccurred()) // Ensure no scan was triggered since deletion failed @@ -817,7 +735,7 @@ var _ = Describe("Library Service", func() { It("starts watcher when creating a new library", func() { library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).NotTo(HaveOccurred()) // Verify watcher was started @@ -846,7 +764,7 @@ var _ = Describe("Library Service", func() { // Update library with new path library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} - err = repo.Update(ctx, "1", *library) + err = repo.Update("1", library) Expect(err).NotTo(HaveOccurred()) // Verify watcher was restarted @@ -866,7 +784,7 @@ var _ = Describe("Library Service", func() { // Update library with same path but different name library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).NotTo(HaveOccurred()) // Verify watcher was NOT restarted (since path didn't change) @@ -881,7 +799,7 @@ var _ = Describe("Library Service", func() { {ID: 1, Name: "Test Library", Path: tempDir}, }) - err := repo.Delete(ctx, "1") + err := repo.Delete("1") Expect(err).NotTo(HaveOccurred()) // Verify watcher was stopped @@ -899,7 +817,7 @@ var _ = Describe("Library Service", func() { }) // Mock deletion to fail by trying to delete non-existent library - err := repo.Delete(ctx, "999") + err := repo.Delete("999") Expect(err).To(HaveOccurred()) // Verify watcher was NOT stopped since deletion failed @@ -911,11 +829,11 @@ var _ = Describe("Library Service", func() { }) Describe("Event Broadcasting", func() { - var repo rest.Persistable[model.Library] + var repo rest.Persistable BeforeEach(func() { - r := service.Repository() - repo = r.(rest.Persistable[model.Library]) + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) // Clear any events from broker broker.Events = []events.Event{} }) @@ -923,7 +841,7 @@ var _ = Describe("Library Service", func() { It("sends refresh event when creating a library", func() { library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - _, err := repo.Save(ctx, library) + _, err := repo.Save(library) Expect(err).NotTo(HaveOccurred()) Expect(broker.Events).To(HaveLen(1)) @@ -936,7 +854,7 @@ var _ = Describe("Library Service", func() { }) library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} - err := repo.Update(ctx, "1", *library) + err := repo.Update("1", library) Expect(err).NotTo(HaveOccurred()) Expect(broker.Events).To(HaveLen(1)) @@ -948,7 +866,7 @@ var _ = Describe("Library Service", func() { {ID: 2, Name: "Library to Delete", Path: tempDir}, }) - err := repo.Delete(ctx, "2") + err := repo.Delete("2") Expect(err).NotTo(HaveOccurred()) Expect(broker.Events).To(HaveLen(1)) @@ -956,13 +874,13 @@ var _ = Describe("Library Service", func() { }) Describe("Plugin Manager Integration", func() { - var repo rest.Persistable[model.Library] + var repo rest.Persistable BeforeEach(func() { // Reset the call count for each test pluginManager.unloadCalls = 0 - r := service.Repository() - repo = r.(rest.Persistable[model.Library]) + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) }) It("calls UnloadDisabledPlugins after successful library deletion", func() { @@ -970,14 +888,14 @@ var _ = Describe("Library Service", func() { {ID: 2, Name: "Library to Delete", Path: tempDir}, }) - err := repo.Delete(ctx, "2") + err := repo.Delete("2") Expect(err).NotTo(HaveOccurred()) Expect(pluginManager.unloadCalls).To(Equal(1)) }) It("does not call UnloadDisabledPlugins when library deletion fails", func() { // Try to delete non-existent library - err := repo.Delete(ctx, "999") + err := repo.Delete("999") Expect(err).To(HaveOccurred()) Expect(pluginManager.unloadCalls).To(Equal(0)) }) diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go index d3e8d72b8..758053042 100644 --- a/core/lyrics/lyrics.go +++ b/core/lyrics/lyrics.go @@ -4,122 +4,56 @@ import ( "context" "strings" - . "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" ) -// maxLegacyLyricsCandidates bounds the duplicate window scanned by the legacy -// artist/title lookup, so source-priority resolution can still reach older -// matches without turning it into an unbounded table scan. -const maxLegacyLyricsCandidates = 10 - -// Provider fetches lyrics for a single media file. It is the contract -// implemented by individual lyrics sources, such as plugins. -type Provider interface { - GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) -} - -// Lyrics resolves lyrics for media files, honoring the configured source -// priority. +// Lyrics can fetch lyrics for a media file. type Lyrics interface { - Provider - GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) + GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) } // PluginLoader discovers and loads lyrics provider plugins. type PluginLoader interface { - LoadLyricsProvider(name string) (Provider, bool) + LoadLyricsProvider(name string) (Lyrics, bool) } type lyricsService struct { - ds model.DataStore pluginLoader PluginLoader } // NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin // system is available. -func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics { - return &lyricsService{ds: ds, pluginLoader: pluginLoader} +func NewLyrics(pluginLoader PluginLoader) Lyrics { + return &lyricsService{pluginLoader: pluginLoader} } // GetLyrics returns lyrics for the given media file, trying sources in the // order specified by conf.Server.LyricsPriority. func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { - return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf}) -} + var lyricsList model.LyricList + var err error -// GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup, -// scanning a bounded window of duplicate matches so source priority still wins -// across them. -func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) { - opts := songsByArtistTitleWithLyricsFirst(artist, title) - opts.Max = maxLegacyLyricsCandidates - mediaFiles, err := l.ds.MediaFile().GetAll(ctx, opts) - if err != nil { - return nil, err - } - if len(mediaFiles) == 0 { - return nil, nil - } - candidates := make([]*model.MediaFile, 0, len(mediaFiles)) - for i := range mediaFiles { - candidates = append(candidates, &mediaFiles[i]) - } - return l.getLyricsForCandidates(ctx, candidates) -} - -func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions { - return model.QueryOptions{ - Sort: "lyrics, updated_at", - Order: "desc", - Filters: And{ - Eq{"missing": false}, - Eq{"title": title}, - Or{ - persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), - persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), - }, - }, - } -} - -func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) { for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") { pattern = strings.TrimSpace(pattern) - if pattern == "" { - continue + switch { + case strings.EqualFold(pattern, "embedded"): + lyricsList, err = fromEmbedded(ctx, mf) + case strings.HasPrefix(pattern, "."): + lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern)) + default: + lyricsList, err = l.fromPlugin(ctx, mf, pattern) } - for _, mf := range mediaFiles { - if mf == nil { - continue - } + if err != nil { + log.Error(ctx, "error getting lyrics", "source", pattern, err) + } - lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern) - if err != nil { - log.Error(ctx, "error getting lyrics", "source", pattern, err) - continue - } - - if len(lyricsList) > 0 { - return lyricsList, nil - } + if len(lyricsList) > 0 { + return lyricsList, nil } } return nil, nil } - -func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) { - switch { - case strings.EqualFold(pattern, "embedded"): - return fromEmbedded(ctx, mf) - case strings.HasPrefix(pattern, "."): - return fromExternalFile(ctx, mf, pattern) - default: - return l.fromPlugin(ctx, mf, pattern) - } -} diff --git a/core/lyrics/lyrics_suite_test.go b/core/lyrics/lyrics_suite_test.go index c9fdcbae8..f87381905 100644 --- a/core/lyrics/lyrics_suite_test.go +++ b/core/lyrics/lyrics_suite_test.go @@ -1,13 +1,9 @@ package lyrics_test import ( - "io/fs" "testing" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -19,17 +15,3 @@ func TestLyrics(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Lyrics Suite") } - -// core/storage/local calls log.Fatal if the default scanner extractor is unregistered -// when constructing any localStorage. Register a no-op so storage.For("file://...") works -// in tests without importing the real extractor. -var _ = BeforeSuite(func() { - local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { - return &noopExtractor{} - }) -}) - -type noopExtractor struct{} - -func (e *noopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } -func (e *noopExtractor) Version() string { return "noop" } diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index b00bcd576..7e837782e 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -13,23 +12,18 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("Lyrics", func() { +var _ = Describe("sources", func() { var mf model.MediaFile var ctx context.Context - embeddedLyrics := model.LyricList{ - model.Lyrics{ - Lang: "xxx", - Line: []model.Line{ - {Value: "This is a set of lyrics"}, - {Value: "That is not good"}, - }, - }, - } + const badLyrics = "This is a set of lyrics\nThat is not good" + unsynced, _ := model.ToLyrics("xxx", badLyrics) + embeddedLyrics := model.LyricList{*unsynced} syncedLyrics := model.LyricList{ model.Lyrics{ @@ -38,80 +32,15 @@ var _ = Describe("Lyrics", func() { Lang: "eng", Line: []model.Line{ { - Start: new(int64(18800)), + Start: gg.P(int64(18800)), Value: "We're no strangers to love", }, { - Start: new(int64(22801)), + Start: gg.P(int64(22801)), Value: "You know the rules and so do I", }, }, - Offset: new(int64(-100)), - Synced: true, - }, - } - - elrcLyrics := model.LyricList{ - model.Lyrics{ - DisplayArtist: "ELRC Artist", - DisplayTitle: "ELRC Song", - Lang: "eng", - Line: []model.Line{ - { - Start: new(int64(1000)), - End: new(int64(3000)), - Value: "Lead words", - Cue: []model.Cue{ - { - Start: new(int64(1000)), - End: new(int64(1500)), - Value: "Lead ", - ByteStart: 0, - ByteEnd: 4, - }, - { - Start: new(int64(1500)), - End: new(int64(3000)), - Value: "words", - ByteStart: 5, - ByteEnd: 9, - }, - }, - }, - { - Start: new(int64(3000)), - Value: "Fallback line", - }, - }, - Synced: true, - }, - } - - ttmlLyrics := model.LyricList{ - model.Lyrics{ - Kind: "main", - Lang: "eng", - Line: []model.Line{ - { - Start: new(int64(18800)), - Value: "We're no strangers to love", - }, - { - Start: new(int64(22800)), - Value: "You know the rules and so do I", - }, - }, - Synced: true, - }, - model.Lyrics{ - Kind: "main", - Lang: "por", - Line: []model.Line{ - { - Start: new(int64(18800)), - Value: "Nao somos estranhos ao amor", - }, - }, + Offset: gg.P(int64(-100)), Synced: true, }, } @@ -131,25 +60,6 @@ var _ = Describe("Lyrics", func() { }, } - srtLyrics := model.LyricList{ - model.Lyrics{ - Lang: "xxx", - Line: []model.Line{ - { - Start: new(int64(18800)), - End: new(int64(22800)), - Value: "We're from subtitles", - }, - { - Start: new(int64(22801)), - End: new(int64(26000)), - Value: "Another subtitle line", - }, - }, - Synced: true, - }, - } - BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -159,104 +69,19 @@ var _ = Describe("Lyrics", func() { Lyrics: string(lyricsJson), Path: "tests/fixtures/test.mp3", } - ctx = GinkgoT().Context() + ctx = context.Background() }) DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) { conf.Server.LyricsPriority = priority - svc := lyrics.NewLyrics(nil, nil) + svc := lyrics.NewLyrics(nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(expected)) }, Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics), Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics), - Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics), - Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics), - Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics), - Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics)) - - It("resolves source priority across duplicate media files", func() { - conf.Server.LyricsPriority = ".ttml,embedded" - embeddedJSON, err := json.Marshal(embeddedLyrics) - Expect(err).To(BeNil()) - - repo := &tests.MockMediaFileRepo{} - repo.SetData(model.MediaFiles{ - { - Lyrics: string(embeddedJSON), - Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3", - }, - { - Lyrics: "[]", - Path: "tests/fixtures/test.mp3", - }, - }) - svc := lyrics.NewLyrics(&tests.MockDataStore{MockedMediaFile: repo}, nil) - - list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") - Expect(err).To(BeNil()) - Expect(list).To(Equal(ttmlLyrics)) - }) - - It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() { - dir, err := os.MkdirTemp("", "lyrics-case-*") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - Expect(os.RemoveAll(dir)).To(Succeed()) - }) - - probe := filepath.Join(dir, "CASECHECK") - Expect(os.WriteFile(probe, []byte("probe"), 0600)).To(Succeed()) - _, err = os.Stat(filepath.Join(dir, "casecheck")) - if err == nil { - Skip("filesystem is case-insensitive") - } - Expect(os.IsNotExist(err)).To(BeTrue()) - - conf.Server.LyricsPriority = ".LRC" - Expect(os.WriteFile(filepath.Join(dir, "song.LRC"), []byte("[00:01.00]Upper suffix"), 0600)).To(Succeed()) - - svc := lyrics.NewLyrics(nil, nil) - list, err := svc.GetLyrics(ctx, &model.MediaFile{ - LibraryPath: dir, - Path: "song.mp3", - }) - - Expect(err).To(BeNil()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line).To(Equal([]model.Line{ - {Start: new(int64(1000)), Value: "Upper suffix"}, - })) - }) - - It("returns a non-Lyricsfile YAML sidecar as plain text, shadowing lower-priority sources", func() { - dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - Expect(os.RemoveAll(dir)).To(Succeed()) - }) - - Expect(os.WriteFile(filepath.Join(dir, "song.yaml"), []byte("title: not lyricsfile\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0600)).To(Succeed()) - - conf.Server.LyricsPriority = ".yaml,.lrc" - svc := lyrics.NewLyrics(nil, nil) - list, err := svc.GetLyrics(ctx, &model.MediaFile{ - LibraryPath: dir, - Path: "song.mp3", - }) - - // ParseLyrics falls back to plain text for any suffix when the content - // doesn't match the structured format, so the .yaml hit is non-empty and - // shadows the lower-priority .lrc entirely. - Expect(err).To(BeNil()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Synced).To(BeFalse()) - Expect(list[0].Line).To(Equal([]model.Line{ - {Value: "title: not lyricsfile"}, - })) - }) + Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics)) Context("Errors", func() { var RegularUserContext = XContext @@ -286,7 +111,7 @@ var _ = Describe("Lyrics", func() { It("should fallback to embedded if an error happens when parsing file", func() { conf.Server.LyricsPriority = ".mp3,embedded" - svc := lyrics.NewLyrics(nil, nil) + svc := lyrics.NewLyrics(nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) @@ -295,7 +120,7 @@ var _ = Describe("Lyrics", func() { It("should return nothing if error happens when trying to parse file", func() { conf.Server.LyricsPriority = ".mp3" - svc := lyrics.NewLyrics(nil, nil) + svc := lyrics.NewLyrics(nil) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(BeEmpty()) @@ -313,7 +138,7 @@ var _ = Describe("Lyrics", func() { It("should return lyrics from a plugin", func() { conf.Server.LyricsPriority = "test-lyrics-plugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(nil, mockLoader) + svc := lyrics.NewLyrics(mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -323,7 +148,7 @@ var _ = Describe("Lyrics", func() { conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" mf.Lyrics = "" // No embedded lyrics mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(nil, mockLoader) + svc := lyrics.NewLyrics(mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -332,7 +157,7 @@ var _ = Describe("Lyrics", func() { It("should skip plugin if embedded has lyrics", func() { conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(nil, mockLoader) + svc := lyrics.NewLyrics(mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // embedded wins @@ -341,7 +166,7 @@ var _ = Describe("Lyrics", func() { It("should skip unknown plugin names gracefully", func() { conf.Server.LyricsPriority = "nonexistent-plugin,embedded" mockLoader.notFound = true - svc := lyrics.NewLyrics(nil, mockLoader) + svc := lyrics.NewLyrics(mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded @@ -351,7 +176,7 @@ var _ = Describe("Lyrics", func() { conf.Server.LyricsPriority = "MyLyricsPlugin" mockLoader.pluginName = "MyLyricsPlugin" mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(nil, mockLoader) + svc := lyrics.NewLyrics(mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(unsyncedLyrics)) @@ -360,56 +185,12 @@ var _ = Describe("Lyrics", func() { It("should handle plugin error gracefully", func() { conf.Server.LyricsPriority = "test-lyrics-plugin,embedded" mockLoader.err = fmt.Errorf("plugin error") - svc := lyrics.NewLyrics(nil, mockLoader) + svc := lyrics.NewLyrics(mockLoader) list, err := svc.GetLyrics(ctx, &mf) Expect(err).To(BeNil()) Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded }) }) - - var _ = Describe("GetLyricsByArtistTitle", func() { - var svc lyrics.Lyrics - var repo *tests.MockMediaFileRepo - var ds *tests.MockDataStore - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.LyricsPriority = "embedded" - repo = &tests.MockMediaFileRepo{} - ds = &tests.MockDataStore{MockedMediaFile: repo} - svc = lyrics.NewLyrics(ds, nil) - }) - - It("bounds the query to a duplicate window", func() { - repo.SetData(model.MediaFiles{}) - _, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") - Expect(err).ToNot(HaveOccurred()) - Expect(repo.Options.Max).To(Equal(10)) - }) - - It("returns nil when no media file matches", func() { - repo.SetData(model.MediaFiles{}) - list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song") - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(BeNil()) - }) - - It("resolves lyrics from the matched media files", func() { - embeddedList, err := model.ParseLyrics(ctx, ".lrc", "eng", []byte("Embedded lyrics line")) - Expect(err).ToNot(HaveOccurred()) - embedded, _ := embeddedList.Main() - embeddedJSON, err := json.Marshal(model.LyricList{embedded}) - Expect(err).ToNot(HaveOccurred()) - repo.SetData(model.MediaFiles{ - {ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)}, - }) - - list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line")) - }) - }) }) type mockPluginLoader struct { @@ -426,7 +207,7 @@ func (m *mockPluginLoader) PluginNames(_ string) []string { return []string{"test-lyrics-plugin"} } -func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) { +func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { if m.notFound { return nil, false } diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go index 23c20122d..82a10ca41 100644 --- a/core/lyrics/sources.go +++ b/core/lyrics/sources.go @@ -3,12 +3,9 @@ package lyrics import ( "context" "errors" - "fmt" - "io" - "io/fs" + "os" "path" - "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/ioutils" @@ -26,46 +23,31 @@ func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, er } func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) { - ext := path.Ext(mf.Path) - sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix - ctx = log.NewContext(ctx, "file", sidecarRelPath) + basePath := mf.AbsolutePath() + ext := path.Ext(basePath) - store, err := storage.For(mf.LibraryPath) - if err != nil { - return nil, fmt.Errorf("getting storage for library: %w", err) - } - fsys, err := store.FS() - if err != nil { - return nil, fmt.Errorf("opening library filesystem: %w", err) - } + externalLyric := basePath[0:len(basePath)-len(ext)] + suffix - f, err := fsys.Open(sidecarRelPath) - if errors.Is(err, fs.ErrNotExist) { - log.Trace(ctx, "no lyrics found at path") + contents, err := ioutils.UTF8ReadFile(externalLyric) + if errors.Is(err, os.ErrNotExist) { + log.Trace(ctx, "no lyrics found at path", "path", externalLyric) return nil, nil } else if err != nil { return nil, err } - defer f.Close() - contents, err := io.ReadAll(ioutils.UTF8Reader(f)) + lyrics, err := model.ToLyrics("xxx", string(contents)) if err != nil { + log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err) return nil, err - } - - list, err := model.ParseLyrics(ctx, suffix, "xxx", contents) - if err != nil { - log.Error(ctx, "error parsing external lyric file", err) - return nil, err - } - - if len(list) == 0 { - log.Trace(ctx, "empty lyrics from external file") + } else if lyrics == nil { + log.Trace(ctx, "empty lyrics from external file", "path", externalLyric) return nil, nil } - log.Trace(ctx, "retrieved lyrics from external file") - return list, nil + log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric) + + return model.LyricList{*lyrics}, nil } // fromPlugin attempts to load lyrics from a plugin with the given name. diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index 7c7922bfd..b3d502101 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -3,19 +3,15 @@ package lyrics import ( "context" "encoding/json" - "path/filepath" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("sources", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) + ctx := context.Background() Describe("fromEmbedded", func() { It("should return nothing for a media file with no lyrics", func() { @@ -30,12 +26,10 @@ var _ = Describe("sources", func() { const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" - syncedList, _ := model.ParseLyrics(ctx, ".lrc", "eng", []byte(syncedLyrics)) - unsyncedList, _ := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(unsyncedLyrics)) - synced, _ := syncedList.Main() - unsynced, _ := unsyncedList.Main() + synced, _ := model.ToLyrics("eng", syncedLyrics) + unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) - expectedList := model.LyricList{synced, unsynced} + expectedList := model.LyricList{*synced, *unsynced} lyricsJson, err := json.Marshal(expectedList) Expect(err).ToNot(HaveOccurred()) @@ -60,94 +54,93 @@ var _ = Describe("sources", func() { }) Describe("fromExternalFile", func() { - var fixturesDir string - - BeforeEach(func() { - // tests.Init sets CWD to the repo root, so "tests/fixtures" resolves correctly. - abs, err := filepath.Abs("tests/fixtures") - Expect(err).ToNot(HaveOccurred()) - fixturesDir = abs - }) - - mf := func(name string) *model.MediaFile { - return &model.MediaFile{LibraryPath: fixturesDir, Path: name} - } - It("should return nil for lyrics that don't exist", func() { - lyrics, err := fromExternalFile(ctx, mf("01 Invisible (RED) Edit Version.mp3"), ".lrc") + mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") Expect(err).To(BeNil()) Expect(lyrics).To(HaveLen(0)) }) - // fromExternalFile delegates format parsing to model.ParseLyrics; the - // per-format parser output is covered exhaustively in the model package. - // Here we only verify each suffix is read from the library FS and routed. - DescribeTable("should read the sidecar file and route its suffix to a parser", - func(name, suffix string, expectSynced bool) { - lyrics, err := fromExternalFile(ctx, mf(name), suffix) - - Expect(err).To(BeNil()) - Expect(lyrics).ToNot(BeEmpty()) - Expect(lyrics[0].Line).ToNot(BeEmpty()) - Expect(lyrics[0].Synced).To(Equal(expectSynced)) - }, - Entry(".lrc synced", "test.mp3", ".lrc", true), - Entry(".elrc enhanced", "test.mp3", ".elrc", true), - Entry(".txt plain", "test.mp3", ".txt", false), - Entry(".srt subtitles", "test.mp3", ".srt", true), - Entry(".ttml multilingual", "test.mp3", ".ttml", true), - Entry(".yaml lyricsfile", "test.mp3", ".yaml", true), - ) - - It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { - lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".lrc") + It("should return synchronized lyrics from a file", func() { + mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") Expect(err).To(BeNil()) + Expect(lyrics).To(Equal(model.LyricList{ + model.Lyrics{ + DisplayArtist: "Rick Astley", + DisplayTitle: "That one song", + Lang: "eng", + Line: []model.Line{ + { + Start: gg.P(int64(18800)), + Value: "We're no strangers to love", + }, + { + Start: gg.P(int64(22801)), + Value: "You know the rules and so do I", + }, + }, + Offset: gg.P(int64(-100)), + Synced: true, + }, + })) + }) + + It("should return unsynchronized lyrics from a file", func() { + mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".txt") + + Expect(err).To(BeNil()) + Expect(lyrics).To(Equal(model.LyricList{ + model.Lyrics{ + Lang: "xxx", + Line: []model.Line{ + { + Value: "We're no strangers to love", + }, + { + Value: "You know the rules and so do I", + }, + }, + Synced: false, + }, + })) + }) + + It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { + // The function looks for , so we need to pass + // a MediaFile with .mp3 path and look for .lrc suffix + mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + + Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeNil()) Expect(lyrics).To(HaveLen(1)) + + // The critical assertion: even with BOM, synced should be true Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(1)) - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) + Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0)))) Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲")) }) It("should handle UTF-16 LE encoded LRC files", func() { - lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".lrc") + mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeNil()) Expect(lyrics).To(HaveLen(1)) + + // UTF-16 should be properly converted to UTF-8 Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(2)) - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) + Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800)))) Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love")) - Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) + Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801)))) Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) }) - - It("should handle TTML files with UTF-8 BOM marker", func() { - lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".ttml") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].Kind).To(Equal("main")) - Expect(lyrics[0].Synced).To(BeTrue()) - Expect(lyrics[0].Line).To(HaveLen(1)) - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) - Expect(lyrics[0].Line[0].Value).To(Equal("BOM test line")) - }) - - It("should handle UTF-16 BE encoded TTML files", func() { - lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".ttml") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].Kind).To(Equal("main")) - Expect(lyrics[0].Synced).To(BeTrue()) - Expect(lyrics[0].Line).To(HaveLen(2)) - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) - Expect(lyrics[0].Line[0].Value).To(Equal("UTF16 line one")) - Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) - Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two")) - }) }) }) diff --git a/core/maintenance.go b/core/maintenance.go index 58265ea2c..13d1141d3 100644 --- a/core/maintenance.go +++ b/core/maintenance.go @@ -2,7 +2,6 @@ package core import ( "context" - "errors" "fmt" "slices" "sync" @@ -15,23 +14,11 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) -var ( - // ErrNotMissing is returned when a remap is attempted from a file not marked as missing. - ErrNotMissing = errors.New("file is not marked as missing") - // ErrTargetMissing is returned when the remap target is itself a missing file. - ErrTargetMissing = errors.New("target file is missing") - // ErrSameFile is returned when the remap source and target are the same file. - ErrSameFile = errors.New("missing and target are the same file") -) - type Maintenance interface { // DeleteMissingFiles deletes specific missing files by their IDs DeleteMissingFiles(ctx context.Context, ids []string) error // DeleteAllMissingFiles deletes all files marked as missing DeleteAllMissingFiles(ctx context.Context) error - // RemapMissingFile moves a missing file's identity onto an existing file, the manual - // counterpart to the scanner's move detection (phaseMissingTracks.moveMatched). - RemapMissingFile(ctx context.Context, missingID, targetID string) error } type maintenanceService struct { @@ -53,87 +40,6 @@ func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error { return s.deleteMissing(ctx, nil) } -func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, targetID string) error { - if missingID == targetID { - return fmt.Errorf("%w: %q", ErrSameFile, missingID) - } - - missing, err := s.ds.MediaFile().Get(ctx, missingID) - if err != nil { - return fmt.Errorf("loading missing file %q: %w", missingID, err) - } - if !missing.Missing { - return fmt.Errorf("%w: %q", ErrNotMissing, missingID) - } - - target, err := s.ds.MediaFile().GetWithParticipants(ctx, targetID) - if err != nil { - return fmt.Errorf("loading target file %q: %w", targetID, err) - } - if target.Missing { - return fmt.Errorf("%w: %q", ErrTargetMissing, targetID) - } - - oldAlbumID, newAlbumID := missing.AlbumID, target.AlbumID - - err = s.ds.WithTx(func(tx model.DataStore) error { - discardedID := target.ID - - // Preserve the original created_at so the remapped track doesn't resurface in "Recently Added" - target.CreatedAt = missing.CreatedAt - target.ID = missing.ID - if err := tx.MediaFile().Put(ctx, target); err != nil { - return fmt.Errorf("update matched track: %w", err) - } - // Unlike the scanner's freshly-imported target, this one may carry history of its own - if err := tx.MediaFile().ReassignReferences(ctx, discardedID, missing.ID); err != nil { - return fmt.Errorf("reassign target references: %w", err) - } - if err := tx.MediaFile().Delete(ctx, discardedID); err != nil { - return fmt.Errorf("delete discarded track: %w", err) - } - - if oldAlbumID != newAlbumID { - oldAlbumTracks, err := tx.MediaFile().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}}) - if err != nil { - return fmt.Errorf("get old album tracks: %w", err) - } - if oldAlbumTracks == 0 { - if err := tx.Album().ReassignAnnotation(ctx, oldAlbumID, newAlbumID); err != nil { - return fmt.Errorf("reassign album annotations: %w", err) - } - if err := tx.Album().CopyAttributes(ctx, oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("copy album attributes: %w", err) - } - } - } - return nil - }) - if err != nil { - log.Error(ctx, "Error remapping missing file", "missing", missing.Path, "target", target.Path, err) - return err - } - - if err := s.ds.GC(ctx); err != nil { - log.Error(ctx, "Error running GC after remapping missing file", err) - return err - } - - // Stats are refreshed synchronously, unlike deleteMissing, so the CLI sees them before it exits. - // album/artist play count aggregates are not recalculated here; they are refreshed by the next scan. - if _, err := s.ds.Artist().RefreshStats(ctx, true); err != nil { - log.Error(ctx, "Error refreshing artist stats after remapping missing file", err) - } - affectedAlbumIDs := []string{newAlbumID} - if oldAlbumID != newAlbumID { - affectedAlbumIDs = append(affectedAlbumIDs, oldAlbumID) - } - if err := s.refreshAlbums(ctx, affectedAlbumIDs); err != nil { - log.Error(ctx, "Error refreshing album stats after remapping missing file", err) - } - return nil -} - // deleteMissing handles the deletion of missing files and triggers necessary cleanup operations func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error { // Track affected album IDs before deletion for refresh @@ -146,10 +52,10 @@ func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) er // Delete missing files within a transaction err = s.ds.WithTx(func(tx model.DataStore) error { if len(ids) == 0 { - _, err := tx.MediaFile().DeleteAllMissing(ctx) + _, err := tx.MediaFile(ctx).DeleteAllMissing() return err } - return tx.MediaFile().DeleteMissing(ctx, ids) + return tx.MediaFile(ctx).DeleteMissing(ids) }) if err != nil { log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err) @@ -162,8 +68,7 @@ func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) er return err } - // Refresh statistics in background. album/artist play count aggregates are not recalculated - // here; they are refreshed by the next scan. + // Refresh statistics in background s.refreshStatsAsync(ctx, affectedAlbumIDs) return nil @@ -192,11 +97,11 @@ func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []strin // refreshAlbumChunk processes a single chunk of album IDs func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error { - albumRepo := s.ds.Album() - mfRepo := s.ds.MediaFile() + albumRepo := s.ds.Album(ctx) + mfRepo := s.ds.MediaFile(ctx) // Batch load existing albums - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ + albums, err := albumRepo.GetAll(model.QueryOptions{ Filters: squirrel.Eq{"album.id": albumIDs}, }) if err != nil { @@ -210,7 +115,7 @@ func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []s } // Batch load all media files for these albums - mediaFiles, err := mfRepo.GetAll(ctx, model.QueryOptions{ + mediaFiles, err := mfRepo.GetAll(model.QueryOptions{ Filters: squirrel.Eq{"album_id": albumIDs}, Sort: "album_id, path", }) @@ -243,7 +148,7 @@ func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []s newAlbum.UpdatedAt = time.Now() newAlbum.CreatedAt = oldAlbum.CreatedAt - if err := albumRepo.Put(ctx, &newAlbum); err != nil { + if err := albumRepo.Put(&newAlbum); err != nil { log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err) // Continue with other albums instead of failing entirely continue @@ -265,7 +170,7 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri } } - mfs, err := s.ds.MediaFile().GetAll(ctx, model.QueryOptions{ + mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: filters, }) if err != nil { @@ -293,7 +198,7 @@ func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbu // Refresh artist stats in background s.wg.Go(func() { bgCtx := request.AddValues(context.Background(), ctx) - if _, err := s.ds.Artist().RefreshStats(bgCtx, true); err != nil { + if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil { log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err) } else { log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files") diff --git a/core/maintenance_test.go b/core/maintenance_test.go index 4ffc098d9..09b442438 100644 --- a/core/maintenance_test.go +++ b/core/maintenance_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "sync" - "time" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -251,167 +250,6 @@ var _ = Describe("Maintenance", func() { }) }) }) - - Describe("RemapMissingFile", func() { - It("relocates the missing file's identity onto the target and runs GC", func() { - created := time.Now().Add(-30 * 24 * time.Hour) - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", Path: "old/song.mp3", AlbumID: "album1", CreatedAt: created, Missing: true}, - {ID: "t1", Path: "new/song.mp3", AlbumID: "album1", Missing: false}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - got, err := mfRepo.Get(ctx, "m1") - Expect(err).ToNot(HaveOccurred()) - Expect(got.Path).To(Equal("new/song.mp3")) // moved to target's location - Expect(got.Missing).To(BeFalse()) - Expect(got.CreatedAt).To(BeTemporally("==", created)) // created_at preserved - exists, _ := mfRepo.Exists(ctx, "t1") - Expect(exists).To(BeFalse()) // discarded row removed - Expect(ds.GCCalled).To(BeTrue()) - }) - - It("moves the target's annotations, bookmarks and playlist entries onto the surviving id", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "t1", AlbumID: "album1", Missing: false}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - Expect(mfRepo.ReassignReferencesCalls).To(HaveKeyWithValue("t1", "m1")) - }) - - It("reassigns album annotations when the old album is emptied", func() { - albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "t1", AlbumID: "album2", Missing: false}, - }) - - mfRepo.SetCountAll(0) - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue("album1", "album2")) - }) - - It("does not reassign album annotations when the old album is not emptied", func() { - albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "m2", AlbumID: "album1", Missing: false}, - {ID: "t1", AlbumID: "album2", Missing: false}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) - }) - - It("does not reassign annotations when the album is unchanged", func() { - albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "t1", AlbumID: "album1", Missing: false}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) - }) - - It("returns ErrNotFound when the missing file does not exist", func() { - mfRepo.SetData(model.MediaFiles{{ID: "t1", Missing: false}}) - - Expect(service.RemapMissingFile(ctx, "nope", "t1")).To(MatchError(model.ErrNotFound)) - }) - - It("refuses to remap from a file that is not missing", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", Missing: false}, - {ID: "t1", Missing: false}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrNotMissing)) - }) - - It("refuses to remap a file onto itself", func() { - mfRepo.SetData(model.MediaFiles{{ID: "m1", Missing: true}}) - - Expect(service.RemapMissingFile(ctx, "m1", "m1")).To(MatchError(ErrSameFile)) - }) - - It("refuses to remap onto a target that is itself missing", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", Missing: true}, - {ID: "t1", Missing: true}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrTargetMissing)) - }) - - It("refreshes artist and album stats right after the remap", func() { - artistRepo := ds.MockedArtist.(*extendedArtistRepo) - albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) - albumRepo.SetData(model.Albums{ - {ID: "album1", Name: "Old Album", SongCount: 2, Size: 1100, Duration: 110}, - {ID: "album2", Name: "New Album", SongCount: 1, Size: 2000, Duration: 200}, - }) - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", Path: "old/1.mp3", Album: "Old Album", AlbumID: "album1", Missing: true, Size: 100, Duration: 10}, - {ID: "k1", Path: "old/2.mp3", Album: "Old Album", AlbumID: "album1", Missing: false, Size: 1000, Duration: 100}, - {ID: "t1", Path: "new/1.mp3", Album: "New Album", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed") - - // The old album lost the remapped track, so its stats are recalculated from the remaining one - oldAlbum, err := albumRepo.Get(ctx, "album1") - Expect(err).ToNot(HaveOccurred()) - Expect(oldAlbum.SongCount).To(Equal(1)) - Expect(oldAlbum.Size).To(Equal(int64(1000))) - Expect(oldAlbum.Duration).To(BeNumerically("==", 100)) - - // The target album keeps the track, now under the missing file's ID - newAlbum, err := albumRepo.Get(ctx, "album2") - Expect(err).ToNot(HaveOccurred()) - Expect(newAlbum.SongCount).To(Equal(1)) - Expect(newAlbum.Size).To(Equal(int64(2000))) - Expect(newAlbum.Duration).To(BeNumerically("==", 200)) - }) - - It("returns an error if GC fails", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "t1", AlbumID: "album1", Missing: false}, - }) - ds.GCError = errors.New("gc failed") - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ContainSubstring("gc failed"))) - }) - - It("preserves the target's participants on the remapped track", func() { - participant := model.Participant{ - Artist: model.Artist{ID: "a1", Name: "Artist", OrderArtistName: "artist", MbzArtistID: "mbz-artist"}, - } - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "t1", AlbumID: "album2", Missing: false, Participants: model.Participants{ - model.RoleArtist: model.ParticipantList{participant}, - }}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - // The surviving row is the missing file's ID, holding the target's data - got, err := mfRepo.GetWithParticipants(ctx, "m1") - Expect(err).ToNot(HaveOccurred()) - Expect(got.Participants).To(HaveKeyWithValue(model.RoleArtist, model.ParticipantList{participant})) - }) - }) }) // Test helper to create a mock DataStore with controllable behavior @@ -447,7 +285,7 @@ type extendedMediaFileRepo struct { deleteMissingError error } -func (m *extendedMediaFileRepo) DeleteMissing(ctx context.Context, ids []string) error { +func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error { m.deleteMissingCalled = true m.deletedIDs = ids if m.deleteMissingError != nil { @@ -470,7 +308,7 @@ type extendedAlbumRepo struct { failOnce bool } -func (m *extendedAlbumRepo) Put(ctx context.Context, album *model.Album) error { +func (m *extendedAlbumRepo) Put(album *model.Album) error { m.mu.Lock() m.putCallCount++ m.lastPutData = album @@ -490,7 +328,7 @@ func (m *extendedAlbumRepo) Put(ctx context.Context, album *model.Album) error { } m.mu.Unlock() - return m.MockAlbumRepo.Put(ctx, album) + return m.MockAlbumRepo.Put(album) } func (m *extendedAlbumRepo) GetPutCallCount() int { @@ -507,7 +345,7 @@ type extendedArtistRepo struct { refreshStatsError error } -func (m *extendedArtistRepo) RefreshStats(ctx context.Context, allArtists bool) (int64, error) { +func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) { m.mu.Lock() m.refreshStatsCalled = true err := m.refreshStatsError @@ -516,7 +354,7 @@ func (m *extendedArtistRepo) RefreshStats(ctx context.Context, allArtists bool) if err != nil { return 0, err } - return m.MockArtistRepo.RefreshStats(ctx, allArtists) + return m.MockArtistRepo.RefreshStats(allArtists) } func (m *extendedArtistRepo) IsRefreshStatsCalled() bool { diff --git a/core/matcher/doc.go b/core/matcher/doc.go deleted file mode 100644 index 1024bc274..000000000 --- a/core/matcher/doc.go +++ /dev/null @@ -1,124 +0,0 @@ -// Package matcher matches song results from external agents (Last.fm, Deezer, -// etc.) to tracks in the local music library, prioritizing accuracy over recall. -// -// It exposes a single [Matcher] type with two entry points that share the same -// matching algorithm: -// -// - [Matcher.MatchSongs] returns an ordered, deduplicated slice of library -// tracks, capped at a requested count. Use it when presenting "similar -// songs" results to a client. -// - [Matcher.MatchSongsIndexed] returns a map from input-song index to matched -// track, with no deduplication. Use it when the caller needs to correlate -// each result back to its input position (e.g. to attach a per-song -// similarity score). -// -// # Algorithm Overview -// -// Each input song is resolved to its best-matching library track using four -// strategies, applied in priority order. A song matched by a higher-priority -// strategy is never reconsidered by a lower-priority one: -// -// 1. Direct ID match: songs with an ID are matched to a MediaFile by ID. -// 2. MusicBrainz Recording ID (MBID) match: songs with an MBID are matched to -// tracks with the same mbz_recording_id. -// 3. ISRC match: songs with an ISRC are matched to tracks carrying that ISRC tag. -// 4. Title+Artist fuzzy match: remaining songs are matched by fuzzy string -// comparison with metadata-specificity scoring (see below). -// -// Priority order is ID > MBID > ISRC > Title+Artist, so more reliable -// identifiers always take precedence over fuzzy text matching. Missing tracks -// (those no longer present on disk) are never matched. -// -// # Fuzzy Matching Details -// -// Title+artist matching uses Jaro-Winkler similarity, with a threshold -// configurable via conf.Server.Matcher.FuzzyThreshold (default 85%). A library -// track must clear the title threshold to be considered. Candidates that clear -// it are ranked by, in order: -// -// 1. Title similarity (Jaro-Winkler score, 0.0–1.0) -// 2. Duration proximity (closer duration scores higher; 1.0 when the agent -// reports no duration) -// 3. Specificity level (0–5, based on metadata precision; higher is better) -// 4. Artist overlap (how many of the song's artists the track credits; more -// shared artists is better) -// 5. Preferred-track flag (enabled by conf.Server.Matcher.PreferStarred; -// prioritizes tracks that are starred or rated >= 4, but only among -// candidates of equal specificity and overlap) -// 6. Album similarity (Jaro-Winkler, as the final tiebreaker) -// -// The specificity levels, from most to least specific, are: -// -// Level 5: Title + Artist identity + Album MBID -// Level 4: Title + Artist identity + Album name (fuzzy) -// Level 3: Title + Artist name + Album name (fuzzy) -// Level 2: Title + Artist identity -// Level 1: Title + Artist name -// Level 0: Title only -// -// "Artist identity" is a match on the artist's Navidrome ID (the strongest signal, -// when a source supplies one) or its MBID. A plain name match is the weaker fallback -// used for an artist with no identity match (e.g. a cover credited to a different -// artist of the same name). -// -// The title phase always requires an agent artist to scope the library query, so -// Level 0 does not mean "no artist": it applies when a candidate matches on title -// but its own artist differs from the query's (e.g. a cover or a featured-artist -// credit), leaving the title as the only shared field. -// -// A song may carry several artists, and the title phase scopes candidate tracks by -// ANY of them: a track credited to at least one shared artist is considered. When a -// source supplies a Navidrome artist ID, that artist is matched directly, skipping -// name/MBID resolution. Among equally specific candidates, the one sharing more of -// the song's artists wins, so a track crediting every collaborator outranks one -// crediting only a single artist. -// -// Each input song is scored independently, so two songs with the same title and -// artist but different durations can resolve to different library tracks (each -// matches the track closest to its own duration). -// -// # Examples -// -// All examples below exercise the title+artist phase, where the interesting -// behavior lives. (Identifier phases — ID, MBID, ISRC — are exact lookups that -// always win over fuzzy matching; they need no illustration.) -// -// Title threshold — a near-miss title still matches; an exact-only threshold -// rejects it: -// -// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} -// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} -// With threshold 85%: match succeeds (similarity ~0.87) -// With threshold 100%: no match (not an exact title) -// -// Specificity ranking — among candidates that clear the title threshold, a -// better album match wins: -// -// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} -// Library has: -// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"} // Level 1 -// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 -// Result: t2 (Level 3 beats Level 1 on the album match) -// -// Duration tiebreak — with title and artist equal, the closest duration wins, -// so two near-identical input songs can resolve to different tracks: -// -// Agent returns: -// {Name: "Untitled", Artist: "Interpol", Duration: 245000} // 4:05 -// {Name: "Untitled", Artist: "Interpol", Duration: 600000} // 10:00 (a live take) -// Library has: -// {ID: "studio", Title: "Untitled", Artist: "Interpol", Duration: 248} // 4:08 -// {ID: "live", Title: "Untitled", Artist: "Interpol", Duration: 602} // 10:02 -// Result: studio for the first song, live for the second -// -// Preferred track — when conf.Server.Matcher.PreferStarred is enabled, a -// starred (or rating >= 4) track is preferred, but only when specificity and -// artist overlap are equal. A more specific match always wins regardless of the -// preferred flag: -// -// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} -// Library has: -// {ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 -// {ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Starred: true} // Level 1, starred -// Result: exact (specificity outranks the starred flag; preferred only breaks ties of equal identity) -package matcher diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index 3eb4db152..54c2a368e 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -3,16 +3,12 @@ package matcher import ( "context" "fmt" - "maps" "math" - "slices" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" "github.com/xrash/smetrics" ) @@ -27,200 +23,259 @@ func New(ds model.DataStore) *Matcher { return &Matcher{ds: ds} } -// MatchSongs matches agent songs to library tracks and returns up to count -// tracks in the input's order. See the package documentation for the matching -// algorithm. +// MatchSongs matches agent song results to local library tracks using a multi-phase +// matching algorithm that prioritizes accuracy over recall. // -// Each library track appears at most once, unless the same input song is -// repeated: identical input songs intentionally yield repeated output tracks, -// while distinct songs that resolve to the same track are deduplicated. Songs -// that cannot be matched are skipped. +// # Algorithm Overview +// +// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the +// local music library using four matching strategies in priority order: +// +// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID +// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with +// matching mbz_recording_id +// 3. ISRC match: Songs with ISRC are matched to tracks with matching ISRC tag +// 4. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison +// with metadata specificity scoring +// +// # Matching Priority +// +// When selecting the final result, matches are prioritized in order: ID > MBID > ISRC > Title+Artist. +// This ensures that more reliable identifiers take precedence over fuzzy text matching. +// +// # Fuzzy Matching Details +// +// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable +// via Matcher.FuzzyThreshold, default 85%). Matches are ranked by: +// +// 1. Title similarity (Jaro-Winkler score, 0.0-1.0) +// 2. Duration proximity (closer duration = higher score, 1.0 if unknown) +// 3. Preferred track flag (enabled by Matcher.PreferStarred; prioritized when the track is +// starred or has rating >= 4) +// 4. Specificity level (0-5, based on metadata precision): +// - Level 5: Title + Artist MBID + Album MBID (most specific) +// - Level 4: Title + Artist MBID + Album name (fuzzy) +// - Level 3: Title + Artist name + Album name (fuzzy) +// - Level 2: Title + Artist MBID +// - Level 1: Title + Artist name +// - Level 0: Title only +// 5. Album similarity (Jaro-Winkler, as final tiebreaker) +// +// # Examples +// +// Example 1 - MBID Priority: +// +// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"} +// Library has: [ +// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"}, +// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, +// ] +// Result: t1 (MBID match takes priority over title+artist) +// +// Example 2 - ISRC Priority: +// +// Agent returns: {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"} +// Library has: [ +// {ID: "t1", Title: "Paranoid Android", Tags: {isrc: ["GBAYE0000351"]}}, +// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"}, +// ] +// Result: t1 (ISRC match takes priority over title+artist) +// +// Example 3 - Specificity Ranking: +// +// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} +// Library has: [ +// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1 +// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3 +// ] +// Result: t2 (Level 3 beats Level 1 due to album match) +// +// Example 4 - Fuzzy Title Matching: +// +// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} +// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} +// With threshold=85%: Match succeeds (similarity ~0.87) +// With threshold=100%: No match (not exact) +// +// # Parameters +// +// - ctx: Context for database operations +// - songs: Slice of agent.Song results from external providers +// - count: Maximum number of matches to return +// +// # Returns +// +// Returns up to 'count' MediaFiles from the library that best match the input songs, +// preserving the original order from the agent. Songs that cannot be matched are skipped. func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { if len(songs) == 0 { return nil, nil } - matches, err := m.resolveMatches(ctx, songs) + + byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) if err != nil { return nil, err } - return orderAndDedup(songs, matches, count), nil + return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil } -// MatchSongsIndexed matches agent songs to library tracks and returns a map from -// input-song index to matched track, letting callers correlate results back to -// the input slice. Unmatched songs are omitted from the map. Unlike MatchSongs, -// results are not deduplicated. See the package documentation for the matching -// algorithm. +// MatchSongsIndexed matches agent song results to local library tracks and returns a map +// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map. +// This preserves original indices, allowing callers to correlate results back to the input slice. func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { if len(songs) == 0 { return nil, nil } - return m.resolveMatches(ctx, songs) -} -// resolveMatches resolves each input song to its best-matching library track, -// keyed by the song's index. Loaders run in priority order (ID > MBID > ISRC > -// Title); each only fills indices not already matched by a higher-priority loader. -func (m *Matcher) resolveMatches(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { + byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + if err != nil { + return nil, err + } + result := make(map[int]model.MediaFile, len(songs)) - if err := m.matchByID(ctx, songs, result); err != nil { - return nil, fmt.Errorf("failed to match tracks by ID: %w", err) - } - if err := m.matchByMBID(ctx, songs, result); err != nil { - return nil, fmt.Errorf("failed to match tracks by MBID: %w", err) - } - if err := m.matchByISRC(ctx, songs, result); err != nil { - return nil, fmt.Errorf("failed to match tracks by ISRC: %w", err) - } - // The title phase is best-effort: a DB failure there must not discard the exact - // matches already found by the higher-priority phases. Only surface it as fatal - // when nothing matched at all. - if err := m.matchByTitle(ctx, songs, result); err != nil { - if len(result) == 0 { - return nil, fmt.Errorf("failed to match tracks by title: %w", err) + for i, t := range songs { + if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found { + result[i] = mf } - log.Warn(ctx, "Title matching failed; returning matches from exact phases only", err) } return result, nil } -// matchByID fills result with direct ID matches. -func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { +func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) { + byID, err = m.loadTracksByID(ctx, songs) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err) + } + byMBID, err = m.loadTracksByMBID(ctx, songs, byID) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err) + } + byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) + } + byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err) + } + return byID, byMBID, byISRC, byTitle, nil +} + +// songMatchedIn checks if a song has already been matched in any of the provided match maps. +func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool { + _, found := lookupByIdentifiers(s, priorMatches...) + return found +} + +// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps. +func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) { + keys := []string{s.ID, s.MBID, s.ISRC} + for _, m := range maps { + for _, key := range keys { + if key != "" { + if mf, ok := m[key]; ok && mf.ID != "" { + return mf, true + } + } + } + } + return model.MediaFile{}, false +} + +// loadTracksByID fetches MediaFiles from the library using direct ID matching. +func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { var ids []string for _, s := range songs { if s.ID != "" { ids = append(ids, s.ID) } } + matches := map[string]model.MediaFile{} if len(ids) == 0 { - return nil + return matches, nil } - res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"media_file.id": ids}, squirrel.Eq{"missing": false}, }, }) if err != nil { - return err + return matches, err } - byID := make(map[string]model.MediaFile, len(res)) for _, mf := range res { - byID[mf.ID] = mf // media_file.id is unique, so no dedup needed - } - for i, s := range songs { - if s.ID == "" { - continue - } - if mf, ok := byID[s.ID]; ok { - result[i] = mf + if _, ok := matches[mf.ID]; !ok { + matches[mf.ID] = mf } } - return nil + return matches, nil } -// matchByMBID fills result with MusicBrainz Recording ID matches, skipping -// songs already matched by a higher-priority loader. -func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { +// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs. +func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { var mbids []string - for i, s := range songs { - if _, done := result[i]; done { - continue - } - if s.MBID != "" { + for _, s := range songs { + if s.MBID != "" && !songMatchedIn(s, priorMatches...) { mbids = append(mbids, s.MBID) } } + matches := map[string]model.MediaFile{} if len(mbids) == 0 { - return nil + return matches, nil } - res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"mbz_recording_id": mbids}, squirrel.Eq{"missing": false}, }, }) if err != nil { - return err + return matches, err } - byMBID := make(map[string]model.MediaFile, len(res)) for _, mf := range res { if id := mf.MbzRecordingID; id != "" { - if _, ok := byMBID[id]; !ok { - byMBID[id] = mf + if _, ok := matches[id]; !ok { + matches[id] = mf } } } - for i, s := range songs { - if _, done := result[i]; done { - continue - } - if s.MBID == "" { - continue - } - if mf, ok := byMBID[s.MBID]; ok { - result[i] = mf - } - } - return nil + return matches, nil } -// matchByISRC fills result with ISRC tag matches, skipping songs already -// matched by a higher-priority loader. -func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { +// loadTracksByISRC fetches MediaFiles from the library using ISRC matching. +func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { var isrcs []string - for i, s := range songs { - if _, done := result[i]; done { - continue - } - if s.ISRC != "" { + for _, s := range songs { + if s.ISRC != "" && !songMatchedIn(s, priorMatches...) { isrcs = append(isrcs, s.ISRC) } } + matches := map[string]model.MediaFile{} if len(isrcs) == 0 { - return nil + return matches, nil } - res, err := m.ds.MediaFile().GetAllByTags(ctx, model.TagISRC, isrcs, model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, Sort: "starred desc, rating desc, year asc, compilation asc", }) if err != nil { - return err + return matches, err } - byISRC := make(map[string]model.MediaFile, len(res)) for _, mf := range res { for _, isrc := range mf.Tags.Values(model.TagISRC) { - if _, ok := byISRC[isrc]; !ok { - byISRC[isrc] = mf + if _, ok := matches[isrc]; !ok { + matches[isrc] = mf } } } - for i, s := range songs { - if _, done := result[i]; done { - continue - } - if s.ISRC == "" { - continue - } - if mf, ok := byISRC[s.ISRC]; ok { - result[i] = mf - } - } - return nil -} - -// queryArtist is one of a song's artists. A non-empty id is matched directly, skipping name/MBID -// resolution; name is pre-sanitized (article-stripped). -type queryArtist struct { - id string - name string - mbid string + return matches, nil } // songQuery represents a normalized query for matching a song to library tracks. type songQuery struct { title string - artists []queryArtist + artist string + artistMBID string album string albumMBID string durationMs uint32 @@ -231,13 +286,11 @@ type matchScore struct { titleSimilarity float64 durationProximity float64 preferredMatch bool - specificityLevel int - artistOverlap int albumSimilarity float64 + specificityLevel int } // betterThan returns true if this score beats another. -// Identity signals (specificity, overlap) outrank the taste signal (preferred). func (s matchScore) betterThan(other matchScore) bool { if s.titleSimilarity != other.titleSimilarity { return s.titleSimilarity > other.titleSimilarity @@ -245,313 +298,106 @@ func (s matchScore) betterThan(other matchScore) bool { if s.durationProximity != other.durationProximity { return s.durationProximity > other.durationProximity } - if s.specificityLevel != other.specificityLevel { - return s.specificityLevel > other.specificityLevel - } - if s.artistOverlap != other.artistOverlap { - return s.artistOverlap > other.artistOverlap - } if s.preferredMatch != other.preferredMatch { return s.preferredMatch } + if s.specificityLevel != other.specificityLevel { + return s.specificityLevel > other.specificityLevel + } return s.albumSimilarity > other.albumSimilarity } // sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization -// when the same track is scored against multiple queries. The `mf` field is a pointer to avoid -// copying the large MediaFile struct into each entry of the sanitized slice. +// when the same track is scored against multiple queries in the inner loop. The `mf` field +// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist +// sanitized slice. type sanitizedTrack struct { - mf *model.MediaFile - title string - artist string - album string - artistIDs map[string]struct{} // query's owned artist IDs this track credits; an ID match is the strongest identity signal - artistMBIDs map[string]struct{} // MBIDs of those artists (artist table; mf.MbzArtistID is not populated on the bulk path) + mf *model.MediaFile + title string + artist string + album string } -func newSanitizedTrack(mf *model.MediaFile, artistIDs, artistMBIDs map[string]struct{}) sanitizedTrack { +func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack { return sanitizedTrack{ - mf: mf, - title: str.SanitizeFieldForSorting(mf.Title), - artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), - album: str.SanitizeFieldForSorting(mf.Album), - artistIDs: artistIDs, - artistMBIDs: artistMBIDs, + mf: mf, + title: str.SanitizeFieldForSorting(mf.Title), + artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), + album: str.SanitizeFieldForSorting(mf.Album), } } -// computeSpecificityLevel determines how well query metadata matches a track (0-5), taking the best -// level achievable across any of the query's artists. Fields must be pre-sanitized. -// -// A query artist counts as an identity match when the track credits its resolved Navidrome ID (the -// strongest signal, our own primary key) or its MBID; that identity then unlocks the album tiers. -// Name matching is the lowest fallback for an artist with no identity match (e.g. a cover credited -// to a different artist by the same name). +// computeSpecificityLevel determines how well query metadata matches a track (0-5). +// The track's title, artist, and album fields must be pre-sanitized. func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { - best := 0 - albumOK := q.album != "" && similarityRatio(t.album, q.album) >= albumThreshold - for _, a := range q.artists { - _, idMember := t.artistIDs[a.id] - _, mbidMember := t.artistMBIDs[a.mbid] - identity := (a.id != "" && idMember) || (a.mbid != "" && mbidMember) - level := 0 - switch { - case identity && q.albumMBID != "" && t.mf.MbzAlbumID == q.albumMBID: - level = 5 - case identity && q.album != "" && albumOK: - level = 4 - case a.name != "" && q.album != "" && t.artist == a.name && albumOK: - level = 3 - case identity: - level = 2 - case a.name != "" && t.artist == a.name: - level = 1 - } - if level > best { - best = level - } + if q.artistMBID != "" && q.albumMBID != "" && + t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { + return 5 } - return best + if q.artistMBID != "" && q.album != "" && + t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold { + return 4 + } + if q.artist != "" && q.album != "" && + t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold { + return 3 + } + if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID { + return 2 + } + if q.artist != "" && t.artist == q.artist { + return 1 + } + if t.title == q.title { + return 0 + } + return -1 } -// indexedQuery pairs a normalized songQuery with the index of the input song -// it came from, so title matches can be written back to result by index. -type indexedQuery struct { - index int - query songQuery -} - -// matchByTitle fills result with fuzzy title+artist matches, skipping songs -// already matched by a higher-priority loader. -func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { - queries := groupQueries(songs, result) +// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering. +func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { + queries := m.buildTitleQueries(songs, priorMatches...) if len(queries) == 0 { - return nil + return map[string]model.MediaFile{}, nil } - resolved, err := m.resolveArtists(ctx, queries) - if err != nil || len(resolved.allIDs) == 0 { - return err - } - - tracks, err := m.fetchTracksCreditedTo(ctx, resolved.allIDs) - if err != nil { - return err - } - - tracksByQuery := resolved.bucketTracks(tracks) threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 - for _, iq := range queries { - sanitized := tracksByQuery[iq.index] - if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found { - result[iq.index] = mf - } - } - return nil -} -// groupQueries builds one normalized title query per still-unmatched song, carrying its full -// artist set. An artist is usable if it carries a Navidrome ID or a non-empty sanitized name; -// songs with no usable artist are skipped (the title phase needs at least one to scope the query). -func groupQueries(songs []agents.Song, result map[int]model.MediaFile) []indexedQuery { - var queries []indexedQuery - for i, s := range songs { - if _, done := result[i]; done { - continue - } - var artists []queryArtist - for _, a := range s.Artists { - name := str.SanitizeFieldForSortingNoArticle(a.Name) - if a.ID == "" && name == "" && a.MBID == "" { - continue - } - artists = append(artists, queryArtist{id: a.ID, name: name, mbid: a.MBID}) - } - if len(artists) == 0 { - continue - } - queries = append(queries, indexedQuery{index: i, query: songQuery{ - title: str.SanitizeFieldForSorting(s.Name), - artists: artists, - album: str.SanitizeFieldForSorting(s.Album), - albumMBID: s.AlbumMBID, - durationMs: s.Duration, - }}) - } - return queries -} - -// resolvedArtists holds the agent artists resolved to artist-table rows, keyed by the query index -// that owns them. Routing is always by stable artist ID, never by name. -type resolvedArtists struct { - byQuery map[int]map[string]struct{} // query index -> set of resolved artist IDs - mbid map[string]string // artist ID -> its MBID (from the artist table) - allIDs []string // every resolved artist ID, for the track lookup -} - -// resolveArtists resolves every artist of every query to artist-table rows. Artists that carry a -// Navidrome ID are owned directly (no name/MBID lookup). The remaining names/MBIDs are resolved in -// one batched query. Ownership is recorded per query index. -func (m *Matcher) resolveArtists(ctx context.Context, queries []indexedQuery) (resolvedArtists, error) { - res := resolvedArtists{ - byQuery: make(map[int]map[string]struct{}, len(queries)), - mbid: make(map[string]string), - } - allIDs := map[string]struct{}{} // de-dupe across fast-path + resolved - - // One pending entry per non-ID artist (carrying the query that owns it). ID-bearing artists - // take the fast-path and are owned directly. - type pendingArtist struct { - name, mbid string - query int - } - var pending []pendingArtist - for _, iq := range queries { - for _, a := range iq.query.artists { - if a.id != "" { - addToSet(res.byQuery, iq.index, a.id) // ID fast-path: own directly - allIDs[a.id] = struct{}{} - continue - } - pending = append(pending, pendingArtist{name: a.name, mbid: a.mbid, query: iq.index}) - } - } - // query indices that supplied each order name / each MBID (skip the empty key — an artist may - // have only one of name/mbid). - nameToQueries := map[string][]int{} - mbidToQueries := map[string][]int{} - for _, p := range pending { - if p.name != "" { - nameToQueries[p.name] = append(nameToQueries[p.name], p.query) - } - if p.mbid != "" { - mbidToQueries[p.mbid] = append(mbidToQueries[p.mbid], p.query) + byArtist := map[string][]songQuery{} + for _, q := range queries { + if q.artist != "" { + byArtist[q.artist] = append(byArtist[q.artist], q) } } - // Query the artist table for name/MBID artists AND for the fast-path IDs (so their MBIDs are - // available for specificity scoring). - var filter squirrel.Or - if len(nameToQueries) > 0 { - filter = append(filter, squirrel.Eq{"order_artist_name": slices.Collect(maps.Keys(nameToQueries))}) - } - if len(mbidToQueries) > 0 { - filter = append(filter, squirrel.Eq{"mbz_artist_id": slices.Collect(maps.Keys(mbidToQueries))}) - } - if len(allIDs) > 0 { - filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))}) - } - if len(filter) > 0 { - artists, err := m.ds.Artist().GetAll(ctx, model.QueryOptions{Filters: filter}) + matches := map[string]model.MediaFile{} + for artist, artistQueries := range byArtist { + tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"order_artist_name": artist}, + squirrel.Eq{"missing": false}, + }, + Sort: "starred desc, rating desc, year asc, compilation asc", + }) if err != nil { - return resolvedArtists{}, err + continue } - for _, a := range artists { - res.mbid[a.ID] = a.MbzArtistID - allIDs[a.ID] = struct{}{} - for _, idx := range nameToQueries[a.OrderArtistName] { - addToSet(res.byQuery, idx, a.ID) - } - if a.MbzArtistID != "" { - for _, idx := range mbidToQueries[a.MbzArtistID] { - addToSet(res.byQuery, idx, a.ID) + + sanitized := make([]sanitizedTrack, len(tracks)) + for i := range tracks { + sanitized[i] = newSanitizedTrack(&tracks[i]) + } + + for _, q := range artistQueries { + if mf, found := m.findBestMatch(q, sanitized, threshold); found { + key := q.title + "|" + q.artist + if _, exists := matches[key]; !exists { + matches[key] = mf } } } } - - res.allIDs = slices.Collect(maps.Keys(allIDs)) - return res, nil -} - -func addToSet(m map[int]map[string]struct{}, k int, v string) { - if m[k] == nil { - m[k] = map[string]struct{}{} - } - m[k][v] = struct{}{} -} - -// scoredTrack is a candidate track for a query; overlap is how many of the query's distinct -// artist IDs the track credits. -type scoredTrack struct { - sanitizedTrack - overlap int -} - -// queryAccum tallies, for one track against one query, the overlap count, the credited artists' -// owned IDs, and their MBIDs. -type queryAccum struct { - overlap int - ids map[string]struct{} - mbids map[string]struct{} -} - -func (r resolvedArtists) bucketTracks(tracks []model.MediaFile) map[int][]scoredTrack { - queriesByArtist := make(map[string][]int) - for idx, ids := range r.byQuery { - for id := range ids { - queriesByArtist[id] = append(queriesByArtist[id], idx) - } - } - - byQuery := make(map[int][]scoredTrack, len(r.byQuery)) - for i := range tracks { - acc := map[int]*queryAccum{} - credited := map[string]struct{}{} - for _, p := range tracks[i].Participants[model.RoleArtist] { - if _, dup := credited[p.ID]; dup { - continue - } - owners, owned := queriesByArtist[p.ID] - if !owned { - continue - } - credited[p.ID] = struct{}{} - mbid := r.mbid[p.ID] // "" if not in the artist-table result - for _, idx := range owners { - a := acc[idx] - if a == nil { - a = &queryAccum{ids: map[string]struct{}{}, mbids: map[string]struct{}{}} - acc[idx] = a - } - a.overlap++ - a.ids[p.ID] = struct{}{} - if mbid != "" { - a.mbids[mbid] = struct{}{} - } - } - } - for idx, a := range acc { - byQuery[idx] = append(byQuery[idx], scoredTrack{ - sanitizedTrack: newSanitizedTrack(&tracks[i], a.ids, a.mbids), - overlap: a.overlap, - }) - } - } - return byQuery -} - -// fetchTracksCreditedTo fetches every non-missing track credited to any of the given artists as -// the main artist (role='artist', not albumartist — that avoids tribute/compilation false -// positives). The non-correlated id IN (subquery) materializes the matching ids once from the -// media_file_artists(artist_id) covering index, far cheaper than a correlated EXISTS that re-runs -// per row. That form isn't expressible via the repository's role filters, so the raw squirrel.Expr -// keeps the media_file_artists schema knowledge here; a dedicated repository method would be the -// cleaner home if this is reused. -func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string) (model.MediaFiles, error) { - if len(artistIDs) == 0 { - return nil, nil - } - args := slice.Map(artistIDs, func(id string) any { return id }) - return m.ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Expr( - "media_file.id IN (SELECT media_file_id FROM media_file_artists "+ - "WHERE role = 'artist' AND artist_id IN ("+squirrel.Placeholders(len(artistIDs))+"))", args...), - squirrel.Eq{"missing": false}, - }, - Sort: "starred desc, rating desc, year asc, compilation asc", - }) + return matches, nil } // durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration @@ -566,32 +412,34 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 } // findBestMatch finds the best matching track using combined title/album similarity and specificity scoring. -func (m *Matcher) findBestMatch(q songQuery, candidates []scoredTrack, threshold float64) (model.MediaFile, bool) { +func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) { var bestMatch model.MediaFile bestScore := matchScore{titleSimilarity: -1} found := false - preferStarred := conf.Server.Matcher.PreferStarred - for _, c := range candidates { - titleSim := similarityRatio(q.title, c.title) + for _, t := range sanitizedTracks { + titleSim := similarityRatio(q.title, t.title) + if titleSim < threshold { continue } + var albumSim float64 if q.album != "" { - albumSim = similarityRatio(q.album, c.album) + albumSim = similarityRatio(q.album, t.album) } + score := matchScore{ titleSimilarity: titleSim, - durationProximity: durationProximity(q.durationMs, c.mf.Duration), - preferredMatch: preferStarred && isPreferredTrack(c.mf), + durationProximity: durationProximity(q.durationMs, t.mf.Duration), + preferredMatch: conf.Server.Matcher.PreferStarred && isPreferredTrack(t.mf), albumSimilarity: albumSim, - specificityLevel: computeSpecificityLevel(q, c.sanitizedTrack, threshold), - artistOverlap: c.overlap, + specificityLevel: computeSpecificityLevel(q, t, threshold), } + if score.betterThan(bestScore) { bestScore = score - bestMatch = *c.mf + bestMatch = *t.mf found = true } } @@ -602,34 +450,66 @@ func isPreferredTrack(mf *model.MediaFile) bool { return mf.Starred || mf.Rating >= 4 } -// orderAndDedup builds the final ordered result from the per-index matches, -// applying the count limit and deduplication. A library track is added at most -// once unless the same input song appears more than once (callers rely on that -// 1:1 positional behavior for identical duplicate inputs). -func orderAndDedup(songs []agents.Song, matches map[int]model.MediaFile, count int) model.MediaFiles { - mfs := make(model.MediaFiles, 0, len(songs)) - addedBy := make(map[string]int, len(songs)) +// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching. +func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { + var queries []songQuery + for _, s := range songs { + if songMatchedIn(s, priorMatches...) { + continue + } + queries = append(queries, songQuery{ + title: str.SanitizeFieldForSorting(s.Name), + artist: str.SanitizeFieldForSortingNoArticle(s.Artist), + artistMBID: s.ArtistMBID, + album: str.SanitizeFieldForSorting(s.Album), + albumMBID: s.AlbumMBID, + durationMs: s.Duration, + }) + } + return queries +} - for i, s := range songs { +// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching +// library tracks using priority order: ID > MBID > ISRC > title+artist. +func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { + mfs := make(model.MediaFiles, 0, len(songs)) + addedBy := make(map[string]agents.Song, len(songs)) + + for _, t := range songs { if len(mfs) == count { break } - mf, found := matches[i] + + mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist) if !found { continue } - if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded { - if !s.Equals(songs[prevIdx]) { + + if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded { + if t != prevSong { continue } } else { - addedBy[mf.ID] = i + addedBy[mf.ID] = t } + mfs = append(mfs, mf) } return mfs } +// findMatchingTrack looks up a song in the match maps using priority order. +func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) { + if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found { + return mf, true + } + key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist) + if mf, ok := byTitleArtist[key]; ok { + return mf, true + } + return model.MediaFile{}, false +} + // similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm. func similarityRatio(a, b string) float64 { if a == b { diff --git a/core/matcher/matcher_internal_test.go b/core/matcher/matcher_internal_test.go index 5b987d937..f111364c1 100644 --- a/core/matcher/matcher_internal_test.go +++ b/core/matcher/matcher_internal_test.go @@ -1,9 +1,6 @@ package matcher import ( - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -54,205 +51,3 @@ var _ = Describe("similarityRatio", func() { Expect(ratio1).To(Equal(ratio2)) }) }) - -var _ = Describe("matcher internals", func() { - It("computeSpecificityLevel uses sanitizedTrack.artistMBIDs for artist-MBID levels", func() { - q := songQuery{ - title: "song", - artists: []queryArtist{{mbid: "artist-mbid-1"}}, - albumMBID: "album-mbid-1", - } - mf := model.MediaFile{Title: "Song", MbzAlbumID: "album-mbid-1"} // note: mf.MbzArtistID intentionally empty - t := newSanitizedTrack(&mf, nil, map[string]struct{}{"artist-mbid-1": {}}) - Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) - }) - - It("computeSpecificityLevel maximizes the level over all query artists", func() { - // First artist does not match; second matches by name with a matching album → level 3. - q := songQuery{ - title: "song", - album: "violator", - artists: []queryArtist{ - {name: "no match"}, - {name: "depeche mode"}, - }, - } - mf := model.MediaFile{Title: "Song", Artist: "Depeche Mode", Album: "Violator"} - t := newSanitizedTrack(&mf, nil, nil) - Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(3)) - }) - - It("scores MBID specificity for any credited artist, not just the last", func() { - q := songQuery{ - title: "song", - artists: []queryArtist{ - {name: "drake", mbid: "mbz-drake"}, - {name: "future", mbid: "mbz-future"}, - }, - album: "wrong album", // force album mismatch so only MBID-level (2) is reachable, not 3+ - } - // Track credits BOTH MBIDs; with the old last-wins string this would only match one. - t := sanitizedTrack{ - mf: &model.MediaFile{}, - title: "song", - artist: "drake", - album: "some other album", - artistMBIDs: map[string]struct{}{"mbz-drake": {}, "mbz-future": {}}, - } - // Either artist's MBID matching yields level 2 (MBID, no album match). The point: it is - // reached via mbz-future too, which the old code would have dropped. - Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(2)) - }) - - It("treats an ID-only artist (no name/MBID) as an identity match, unlocking album tiers", func() { - // A plugin that supplies only a Navidrome artist ID: name and mbid are empty. The ID is the - // strongest identity signal, so the track's album still elevates specificity above 0. - q := songQuery{ - title: "song", - artists: []queryArtist{{id: "artist-1"}}, - album: "violator", - albumMBID: "album-mbid-1", - } - // Track credits the owned artist ID; no MBID anywhere (untagged library / ID-only plugin). - mf := model.MediaFile{Title: "Song", Album: "Violator", MbzAlbumID: "album-mbid-1"} - t := newSanitizedTrack(&mf, map[string]struct{}{"artist-1": {}}, nil) - // Album MBID matches → level 5 via the ID identity, where the old code scored 0. - Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) - - // Same artist, album name matches but no album MBID → level 4 via the ID identity. - q.albumMBID = "" - mf2 := model.MediaFile{Title: "Song", Album: "Violator"} - t2 := newSanitizedTrack(&mf2, map[string]struct{}{"artist-1": {}}, nil) - Expect(computeSpecificityLevel(q, t2, 0.85)).To(Equal(4)) - }) -}) - -var _ = Describe("groupQueries", func() { - It("builds one query per unmatched song carrying all artists", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].index).To(Equal(0)) - Expect(queries[0].query.title).To(Equal("song a")) - Expect(queries[0].query.artists).To(HaveLen(2)) - Expect(queries[0].query.artists[0].name).To(Equal("drake")) - Expect(queries[0].query.artists[1].name).To(Equal("future")) - }) - - It("strips leading articles from artist names", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: "The Drake"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].query.artists).To(HaveLen(1)) - Expect(queries[0].query.artists[0].name).To(Equal("drake")) - }) - - It("keeps an artist that carries only an ID (empty name)", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].query.artists).To(HaveLen(1)) - Expect(queries[0].query.artists[0].id).To(Equal("ar-x")) - Expect(queries[0].query.artists[0].name).To(Equal("")) - }) - - It("keeps an artist that carries only an MBID (empty id and name)", func() { - // ListenBrainz collaborators arrive as MBID-only when the API supplies a combined display - // name; the MBID is a usable identity signal and must not be dropped. - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{MBID: "mbz-future"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].query.artists).To(HaveLen(1)) - Expect(queries[0].query.artists[0].id).To(Equal("")) - Expect(queries[0].query.artists[0].name).To(Equal("")) - Expect(queries[0].query.artists[0].mbid).To(Equal("mbz-future")) - }) - - It("drops a fully-empty artist (no id, name, or mbid) but keeps usable ones", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{}, {Name: "Future"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].query.artists).To(HaveLen(1)) - Expect(queries[0].query.artists[0].name).To(Equal("future")) - }) - - It("skips already-matched songs and songs with no usable artist", func() { - songs := []agents.Song{ - {Name: "Already Matched", Artists: []agents.Artist{{Name: "Drake"}}}, - {Name: "No Artist"}, - {Name: "Song C", Artists: []agents.Artist{{Name: "Future"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{0: {ID: "done"}}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].index).To(Equal(2)) - Expect(queries[0].query.artists[0].name).To(Equal("future")) - }) -}) - -var _ = Describe("bucketTracks", func() { - It("scores tracks by how many of the query's artists they credit (overlap)", func() { - r := resolvedArtists{ - byQuery: map[int]map[string]struct{}{ - 0: {"ar-1": {}, "ar-2": {}}, - }, - mbid: map[string]string{}, - } - trackA := model.MediaFile{ID: "a", Title: "A", - Participants: artistParticipants( - model.Artist{ID: "ar-1", OrderArtistName: "one"}, - model.Artist{ID: "ar-2", OrderArtistName: "two"}, - ), - } - trackB := model.MediaFile{ID: "b", Title: "B", - Participants: artistParticipants(model.Artist{ID: "ar-1", OrderArtistName: "one"}), - } - byQuery := r.bucketTracks(model.MediaFiles{trackA, trackB}) - Expect(byQuery[0]).To(HaveLen(2)) - overlaps := map[string]int{} - for _, st := range byQuery[0] { - overlaps[st.mf.ID] = st.overlap - } - Expect(overlaps["a"]).To(Equal(2)) - Expect(overlaps["b"]).To(Equal(1)) - }) -}) - -var _ = Describe("resolveArtists ID fast-path", func() { - It("owns an artist supplied by ID without a name match", func() { - ctx := GinkgoT().Context() - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{ - {ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist", MbzArtistID: "mbz-x"}, - }) - ds := &tests.MockDataStore{MockedArtist: artistRepo} - m := New(ds) - - queries := []indexedQuery{ - {index: 0, query: songQuery{title: "song", artists: []queryArtist{{id: "ar-x"}}}}, - } - res, err := m.resolveArtists(ctx, queries) - Expect(err).ToNot(HaveOccurred()) - Expect(res.byQuery[0]).To(HaveKey("ar-x")) - Expect(res.allIDs).To(ContainElement("ar-x")) - Expect(res.mbid["ar-x"]).To(Equal("mbz-x")) - }) -}) - -// artistParticipants builds a Participants map crediting the given artists under RoleArtist. -func artistParticipants(artists ...model.Artist) model.Participants { - list := make(model.ParticipantList, len(artists)) - for i, a := range artists { - list[i] = model.Participant{Artist: a} - } - return model.Participants{model.RoleArtist: list} -} diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 0f5389ca5..42e3ec88d 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -3,7 +3,6 @@ package matcher_test import ( "context" "errors" - "strings" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" @@ -20,7 +19,6 @@ import ( var _ = Describe("Matcher", func() { var ds model.DataStore var mediaFileRepo *mockMediaFileRepo - var artistRepo *mockArtistRepo var ctx context.Context var m *matcher.Matcher @@ -28,13 +26,11 @@ 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) }) @@ -57,49 +53,26 @@ var _ = Describe("Matcher", func() { Return(matches, nil).Once() } - // 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() { + // 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() { 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() - } - - // 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())). + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). Return(model.MediaFiles{}, 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() + // 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() } Describe("MatchSongs", func() { @@ -107,7 +80,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", Artists: []agents.Artist{{Name: "Some Artist"}}}, + {ID: "track-1", Name: "Some Song", Artist: "Some Artist"}, } idMatch := model.MediaFile{ ID: "track-1", Title: "Some Song", Artist: "Some Artist", @@ -125,7 +98,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", Artists: []agents.Artist{{Name: "Radiohead"}}}, + {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}, } mbidMatch := model.MediaFile{ ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead", @@ -144,7 +117,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", Artists: []agents.Artist{{Name: "Radiohead"}}}, + {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}, } isrcMatch := model.MediaFile{ ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead", @@ -163,13 +136,12 @@ 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", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, + {Name: "Enjoy the Silence", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{titleMatch}) + setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -179,13 +151,12 @@ var _ = Describe("Matcher", func() { It("matches songs with fuzzy title similarity", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}}, + {Name: "Bohemian Rhapsody", Artist: "Queen"}, } fuzzyMatch := model.MediaFile{ ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", - Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - allowTitlePhase(model.MediaFiles{fuzzyMatch}) + setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -195,14 +166,12 @@ var _ = Describe("Matcher", func() { It("does not match completely different titles", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}}, + {Name: "Yesterday", Artist: "The Beatles"}, } differentTracks := model.MediaFiles{ - {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles", - Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), - }, + {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, } - allowTitlePhase(differentTracks) + setupTitleOnlyExpectations(differentTracks) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -213,14 +182,13 @@ 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)", Artists: []agents.Artist{{Name: "Queen"}}}, - {Name: "Bohemian Rhapsody (Original Mix)", Artists: []agents.Artist{{Name: "Queen"}}}, + {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, } libraryTrack := model.MediaFile{ ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", - Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), } - allowTitlePhase(model.MediaFiles{libraryTrack}) + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -230,14 +198,13 @@ 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", 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"}, + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{libraryTrack}) + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -253,7 +220,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", Artists: []agents.Artist{{Name: "Artist"}}}, + {ID: "track-id", Name: "Song", MBID: "mbid-1", Artist: "Artist"}, } idMatch := model.MediaFile{ ID: "track-id", Title: "Song", Artist: "Artist", @@ -271,22 +238,16 @@ var _ = Describe("Matcher", func() { It("returns at most 'count' results", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: "Artist"}}}, - {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, - {Name: "Song C", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song A", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song C", Artist: "Artist"}, } tracks := model.MediaFiles{ - {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"}), - }, + {ID: "a", Title: "Song A", Artist: "Artist"}, + {ID: "b", Title: "Song B", Artist: "Artist"}, + {ID: "c", Title: "Song C", Artist: "Artist"}, } - allowTitlePhase(tracks) + setupTitleOnlyExpectations(tracks) result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -300,277 +261,14 @@ 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", 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"}}}, + {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"}, } mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"} mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"} @@ -589,8 +287,8 @@ var _ = Describe("Matcher", func() { It("preserves original indices when some songs don't match", func() { songs := []agents.Song{ - {Name: "Unknown Song", Artists: []agents.Artist{{Name: "Unknown Artist"}}}, - {ID: "track-1", Name: "Known Song", Artists: []agents.Artist{{Name: "Known Artist"}}}, + {Name: "Unknown Song", Artist: "Unknown Artist"}, + {ID: "track-1", Name: "Known Song", Artist: "Known Artist"}, } mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"} @@ -621,18 +319,16 @@ 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", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid-123"}}, Album: "Violator", AlbumMBID: "album-mbid-456"}, + {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, } - allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -644,17 +340,15 @@ 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", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, + {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, } - allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -666,17 +360,15 @@ 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", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, + {Name: "Similar Song", Artist: "Depeche Mode"}, } - allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -690,7 +382,7 @@ var _ = Describe("Matcher", func() { {Name: "Similar Song"}, } - allowTitlePhase(model.MediaFiles{}) + setupTitleOnlyExpectations(model.MediaFiles{}) result, err := m.MatchSongs(ctx, songs, 5) @@ -699,23 +391,17 @@ 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!", - 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"}), - } + 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", 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"}, + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}, + {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, } - allowTitlePhase(model.MediaFiles{cover1, cover2, cover3}) + setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) result, err := m.MatchSongs(ctx, songs, 5) @@ -729,24 +415,21 @@ 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", - Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), + 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", 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"}}}, + {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, + {Name: "Song B", Artist: "Artist Two"}, } - allowTitlePhase(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) + setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -755,31 +438,6 @@ 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() { @@ -788,15 +446,13 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Paranoid Android", Artists: []agents.Artist{{Name: "Radiohead"}}}, + {Name: "Paranoid Android", Artist: "Radiohead"}, } artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", - Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), - }, + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, } - allowTitlePhase(artistTracks) + setupTitleOnlyExpectations(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -809,15 +465,13 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 85 songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}}, + {Name: "Bohemian Rhapsody", Artist: "Queen"}, } artistTracks := model.MediaFiles{ - {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", - Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), - }, + {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, } - allowTitlePhase(artistTracks) + setupTitleOnlyExpectations(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -832,15 +486,13 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 100 songs := []agents.Song{ - {Name: "Paranoid Android", Artists: []agents.Artist{{Name: "Radiohead"}}}, + {Name: "Paranoid Android", Artist: "Radiohead"}, } artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", - Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), - }, + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, } - allowTitlePhase(artistTracks) + setupTitleOnlyExpectations(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -854,15 +506,13 @@ var _ = Describe("Matcher", func() { conf.Server.Matcher.FuzzyThreshold = 75 songs := []agents.Song{ - {Name: "Song", Artists: []agents.Artist{{Name: "Artist"}}}, + {Name: "Song", Artist: "Artist"}, } artistTracks := model.MediaFiles{ - {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist", - Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), - }, + {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, } - allowTitlePhase(artistTracks) + setupTitleOnlyExpectations(artistTracks) result, err := m.MatchSongs(ctx, songs, 5) @@ -881,18 +531,16 @@ var _ = Describe("Matcher", func() { It("matches album with (Remaster) suffix", func() { songs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -903,18 +551,16 @@ var _ = Describe("Matcher", func() { It("matches album with (Deluxe Edition) suffix", func() { songs := []agents.Song{ - {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, + {Name: "Enjoy the Silence", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{wrongMatch, correctMatch}) + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -925,18 +571,16 @@ var _ = Describe("Matcher", func() { It("prefers exact album match over fuzzy album match", func() { songs := []agents.Song{ - {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, + {Name: "Enjoy the Silence", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{fuzzyMatch, exactMatch}) + setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -945,68 +589,46 @@ var _ = Describe("Matcher", func() { Expect(result[0].ID).To(Equal("exact")) }) - It("prefers a more specific match over a starred track when PreferStarred is enabled", func() { + It("prefers starred songs over better album match when enabled", func() { conf.Server.Matcher.PreferStarred = true songs := []agents.Song{ - {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, + {Name: "Enjoy the Silence", Artist: "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}, - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), + ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Starred: true}, } - allowTitlePhase(model.MediaFiles{albumMatch, starredTrack}) + + setupTitleOnlyExpectations(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("album-match")) // specificity now outranks the starred flag + Expect(result[0].ID).To(Equal("starred")) }) - It("prefers a more specific match over a 4-star track when PreferStarred is enabled", func() { + It("prefers 4-star songs over better album match when enabled", func() { conf.Server.Matcher.PreferStarred = true songs := []agents.Song{ - {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, + {Name: "Enjoy the Silence", Artist: "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}, - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), + ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Annotations: model.Annotations{Rating: 4}, } - 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("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}) + setupTitleOnlyExpectations(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("starred")) // preferred still wins the tie + Expect(result[0].ID).To(Equal("rated")) }) }) @@ -1017,18 +639,16 @@ var _ = Describe("Matcher", func() { It("prefers tracks with matching duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, + {Name: "Similar Song", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{wrongDuration, correctMatch}) + setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) result, err := m.MatchSongs(ctx, songs, 5) @@ -1039,14 +659,13 @@ var _ = Describe("Matcher", func() { It("matches tracks with close duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, + {Name: "Similar Song", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{closeDuration}) + setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -1057,18 +676,16 @@ var _ = Describe("Matcher", func() { It("prefers closer duration over farther duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, + {Name: "Similar Song", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{farDuration, closeDuration}) + setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -1079,14 +696,13 @@ var _ = Describe("Matcher", func() { It("still matches when no tracks have matching duration", func() { songs := []agents.Song{ - {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, + {Name: "Similar Song", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{differentDuration}) + setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) result, err := m.MatchSongs(ctx, songs, 5) @@ -1097,18 +713,16 @@ var _ = Describe("Matcher", func() { It("prefers title match over duration match when titles differ", func() { songs := []agents.Song{ - {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, + {Name: "Similar Song", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{differentTitle, correctTitle}) + setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) result, err := m.MatchSongs(ctx, songs, 5) @@ -1119,14 +733,13 @@ var _ = Describe("Matcher", func() { It("matches without duration filtering when agent duration is 0", func() { songs := []agents.Song{ - {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 0}, + {Name: "Similar Song", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{anyTrack}) + setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -1137,14 +750,13 @@ var _ = Describe("Matcher", func() { It("handles very short songs with close duration", func() { songs := []agents.Song{ - {Name: "Short Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 30000}, + {Name: "Short Song", Artist: "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"}), } - allowTitlePhase(model.MediaFiles{shortTrack}) + setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -1152,30 +764,6 @@ 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() { @@ -1185,17 +773,16 @@ var _ = Describe("Matcher", func() { It("handles mixed scenario with both identical and different input songs", func() { songs := []agents.Song{ - {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"}, + {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"}, } libraryTrack := model.MediaFile{ ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", - Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), } - allowTitlePhase(model.MediaFiles{libraryTrack}) + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) result, err := m.MatchSongs(ctx, songs, 5) @@ -1207,21 +794,15 @@ var _ = Describe("Matcher", func() { It("does not deduplicate songs that match different library tracks", func() { songs := []agents.Song{ - {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"}), + {Name: "Song A", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song C", Artist: "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"} - allowTitlePhase(model.MediaFiles{trackA, trackB, trackC}) + setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) result, err := m.MatchSongs(ctx, songs, 5) @@ -1234,19 +815,15 @@ var _ = Describe("Matcher", func() { It("respects count limit after deduplication", func() { songs := []agents.Song{ - {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"}), + {Name: "Song A", Artist: "Artist"}, + {Name: "Song A (Live)", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song B (Remix)", Artist: "Artist"}, } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - allowTitlePhase(model.MediaFiles{trackA, trackB}) + setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) result, err := m.MatchSongs(ctx, songs, 2) @@ -1267,7 +844,7 @@ func newMockMediaFileRepo() *mockMediaFileRepo { return &mockMediaFileRepo{} } -func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { argsSlice := make([]any, len(options)) for i, v := range options { argsSlice[i] = v @@ -1279,8 +856,8 @@ func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOp return args.Get(0).(model.MediaFiles), args.Error(1) } -func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { - return m.GetAll(ctx, options...) +func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { + return m.GetAll(options...) } func (m *mockMediaFileRepo) SetError(hasError bool) { @@ -1289,27 +866,6 @@ func (m *mockMediaFileRepo) SetError(hasError bool) { } } -type mockArtistRepo struct { - mock.Mock - model.ArtistRepository -} - -func newMockArtistRepo() *mockArtistRepo { - return &mockArtistRepo{} -} - -func (m *mockArtistRepo) GetAll(_ context.Context, 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 { @@ -1339,30 +895,3 @@ 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 1d2ff5df4..f069d3fb6 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -10,8 +10,6 @@ import ( "path/filepath" "runtime" "runtime/debug" - "slices" - "strings" "sync" "sync/atomic" "time" @@ -27,7 +25,6 @@ import ( "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/singleton" ) @@ -48,11 +45,11 @@ type insightsCollector struct { func GetInstance(ds model.DataStore) Insights { return singleton.GetInstance(func() *insightsCollector { - id, err := ds.Property().Get(context.TODO(), consts.InsightsIDKey) + id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey) if err != nil { log.Trace("Could not get Insights ID from DB. Creating one", err) id = uuid.NewString() - err = ds.Property().Put(context.TODO(), consts.InsightsIDKey, id) + err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id) if err != nil { log.Trace("Could not save Insights ID to DB", err) } @@ -88,7 +85,7 @@ func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, succe } func (c *insightsCollector) sendInsights(ctx context.Context) { - count, err := c.ds.User().CountAll(ctx, model.QueryOptions{}) + count, err := c.ds.User(ctx).CountAll(model.QueryOptions{}) if err != nil { log.Trace(ctx, "Could not check user count", err) return @@ -97,7 +94,9 @@ func (c *insightsCollector) sendInsights(ctx context.Context) { log.Trace(ctx, "No users found, skipping Insights data collection") return } - hc := httpclient.New(consts.DefaultHttpClientTimeOut) + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } data := c.collect(ctx) if data == nil { return @@ -154,17 +153,6 @@ func getFSInfo(path string) *insights.FSInfo { return &info } -// installedPackage returns the official installer format used, as written by our own packagers. -func installedPackage() string { - data, _ := os.ReadFile(filepath.Join(conf.Server.DataFolder.String(), ".package")) - return strings.TrimSpace(string(data)) -} - -// hostingPlatform is env-based, not a file, as app stores can only inject env vars into our image. -func hostingPlatform() string { - return strings.TrimSpace(os.Getenv("ND_PLATFORM")) -} - var staticData = sync.OnceValue(func() insights.Data { // Basic info data := insights.Data{ @@ -177,8 +165,11 @@ var staticData = sync.OnceValue(func() insights.Data { data.OS.Containerized = consts.InContainer // Install info - data.OS.Package = installedPackage() - data.Platform = hostingPlatform() + packageFilename := filepath.Join(conf.Server.DataFolder, ".package") + packageFileData, err := os.ReadFile(packageFilename) + if err == nil { + data.OS.Package = string(packageFileData) + } // OS info data.OS.Type = runtime.GOOS @@ -188,18 +179,18 @@ var staticData = sync.OnceValue(func() insights.Data { // FS info data.FS.Music = getFSInfo(conf.Server.MusicFolder) - data.FS.Data = getFSInfo(conf.Server.DataFolder.String()) - if conf.Server.CacheFolder.String() != "" { - data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String()) + data.FS.Data = getFSInfo(conf.Server.DataFolder) + if conf.Server.CacheFolder != "" { + data.FS.Cache = getFSInfo(conf.Server.CacheFolder) } - if conf.Server.Backup.Path.String() != "" { - data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String()) + if conf.Server.Backup.Path != "" { + data.FS.Backup = getFSInfo(conf.Server.Backup.Path) } // Config info data.Config.LogLevel = conf.Server.LogLevel data.Config.LogFileConfigured = conf.Server.LogFile != "" - data.Config.TLSConfigured = conf.Server.TLSEnabled() + data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != "" data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload @@ -232,7 +223,6 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup - data.Config.EnableScheduledDBAnalyze = conf.Server.EnableScheduledDBAnalyze data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != "" data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID data.Config.HasCustomTags = len(conf.Server.Tags) > 0 @@ -246,45 +236,41 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { // Library info var err error - data.Library.Tracks, err = c.ds.MediaFile().CountAll(ctx) + data.Library.Tracks, err = c.ds.MediaFile(ctx).CountAll() if err != nil { log.Trace(ctx, "Error reading tracks count", err) } - data.Library.Albums, err = c.ds.Album().CountAll(ctx) + data.Library.Albums, err = c.ds.Album(ctx).CountAll() if err != nil { log.Trace(ctx, "Error reading albums count", err) } - data.Library.Artists, err = c.ds.Artist().CountAll(ctx) + data.Library.Artists, err = c.ds.Artist(ctx).CountAll() if err != nil { log.Trace(ctx, "Error reading artists count", err) } - data.Library.Playlists, err = c.ds.Playlist().CountAll(ctx) + data.Library.Playlists, err = c.ds.Playlist(ctx).CountAll() if err != nil { log.Trace(ctx, "Error reading playlists count", err) } - data.Library.Shares, err = c.ds.Share().CountAll(ctx) + data.Library.Shares, err = c.ds.Share(ctx).CountAll() if err != nil { log.Trace(ctx, "Error reading shares count", err) } - data.Library.Radios, err = c.ds.Radio().CountAll(ctx) + data.Library.Radios, err = c.ds.Radio(ctx).Count() if err != nil { log.Trace(ctx, "Error reading radios count", err) } - libs, err := c.ds.Library().GetAll(ctx) + data.Library.Libraries, err = c.ds.Library(ctx).CountAll() if err != nil { - log.Trace(ctx, "Error reading libraries", err) + log.Trace(ctx, "Error reading libraries count", err) } - data.Library.Libraries = int64(len(libs)) - if slices.ContainsFunc(libs, func(lib model.Library) bool { return lib.PIDAlbum != "" || lib.PIDTrack != "" }) { - data.Config.HasCustomPID = true - } - data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{ + data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)}, }) if err != nil { log.Trace(ctx, "Error reading active users count", err) } - data.Library.FileSuffixes, err = c.ds.MediaFile().CountBySuffix(ctx) + data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix() if err != nil { log.Trace(ctx, "Error reading file suffixes count", err) } @@ -302,7 +288,7 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { // Collect active players if permitted if conf.Server.DevEnablePlayerInsights { - data.Library.ActivePlayers, err = c.ds.Player().CountByClient(ctx, model.QueryOptions{ + data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{ Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)}, }) if err != nil { @@ -329,7 +315,7 @@ func (c *insightsCollector) collect(ctx context.Context) []byte { // hasSmartPlaylists checks if there are any smart playlists (playlists with rules) func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) { - count, err := c.ds.Playlist().CountAll(ctx, model.QueryOptions{ + count, err := c.ds.Playlist(ctx).CountAll(model.QueryOptions{ Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}}, }) return count > 0, err diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 8559d4204..34648a49b 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -4,9 +4,7 @@ type Data struct { InsightsID string `json:"id"` Version string `json:"version"` Uptime int64 `json:"uptime"` - // Platform is the app store or hosting provider this instance runs on, self-declared via ND_PLATFORM - Platform string `json:"platform,omitempty"` - Build struct { + Build struct { // build settings used by the Go compiler Settings map[string]string `json:"settings"` GoVersion string `json:"goVersion"` @@ -45,46 +43,45 @@ type Data struct { FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"` } `json:"library"` Config struct { - LogLevel string `json:"logLevel,omitempty"` - LogFileConfigured bool `json:"logFileConfigured,omitempty"` - TLSConfigured bool `json:"tlsConfigured,omitempty"` - ScannerEnabled bool `json:"scannerEnabled,omitempty"` - ScannerExtractor string `json:"scannerExtractor,omitempty"` - ScanSchedule string `json:"scanSchedule,omitempty"` - ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` - ScanOnStartup bool `json:"scanOnStartup,omitempty"` - EnableScheduledDBAnalyze bool `json:"enableScheduledDBAnalyze,omitempty"` - TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"` - ImageCacheSize string `json:"imageCacheSize,omitempty"` - EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"` - EnableDownloads bool `json:"enableDownloads,omitempty"` - EnableSharing bool `json:"enableSharing,omitempty"` - EnableStarRating bool `json:"enableStarRating,omitempty"` - EnableLastFM bool `json:"enableLastFM,omitempty"` - EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` - EnableDeezer bool `json:"enableDeezer,omitempty"` - EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` - EnableJukebox bool `json:"enableJukebox,omitempty"` - EnablePrometheus bool `json:"enablePrometheus,omitempty"` - EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` - CoverArtQuality int `json:"coverArtQuality,omitempty"` - EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` - UICoverArtSize int `json:"uiCoverArtSize,omitempty"` - EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` - EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` - SessionTimeout uint64 `json:"sessionTimeout,omitempty"` - SearchFullString bool `json:"searchFullString,omitempty"` - SearchBackend string `json:"searchBackend,omitempty"` - RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` - PreferSortTags bool `json:"preferSortTags,omitempty"` - BackupSchedule string `json:"backupSchedule,omitempty"` - BackupCount int `json:"backupCount,omitempty"` - DevActivityPanel bool `json:"devActivityPanel,omitempty"` - DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` - HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` - ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` - HasCustomPID bool `json:"hasCustomPID,omitempty"` - HasCustomTags bool `json:"hasCustomTags,omitempty"` + LogLevel string `json:"logLevel,omitempty"` + LogFileConfigured bool `json:"logFileConfigured,omitempty"` + TLSConfigured bool `json:"tlsConfigured,omitempty"` + ScannerEnabled bool `json:"scannerEnabled,omitempty"` + ScannerExtractor string `json:"scannerExtractor,omitempty"` + ScanSchedule string `json:"scanSchedule,omitempty"` + ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` + ScanOnStartup bool `json:"scanOnStartup,omitempty"` + TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"` + ImageCacheSize string `json:"imageCacheSize,omitempty"` + EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"` + EnableDownloads bool `json:"enableDownloads,omitempty"` + EnableSharing bool `json:"enableSharing,omitempty"` + EnableStarRating bool `json:"enableStarRating,omitempty"` + EnableLastFM bool `json:"enableLastFM,omitempty"` + EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` + EnableDeezer bool `json:"enableDeezer,omitempty"` + EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` + EnableJukebox bool `json:"enableJukebox,omitempty"` + EnablePrometheus bool `json:"enablePrometheus,omitempty"` + EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` + CoverArtQuality int `json:"coverArtQuality,omitempty"` + EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` + UICoverArtSize int `json:"uiCoverArtSize,omitempty"` + EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` + EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` + SessionTimeout uint64 `json:"sessionTimeout,omitempty"` + SearchFullString bool `json:"searchFullString,omitempty"` + SearchBackend string `json:"searchBackend,omitempty"` + RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` + PreferSortTags bool `json:"preferSortTags,omitempty"` + BackupSchedule string `json:"backupSchedule,omitempty"` + BackupCount int `json:"backupCount,omitempty"` + DevActivityPanel bool `json:"devActivityPanel,omitempty"` + DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` + HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` + ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` + HasCustomPID bool `json:"hasCustomPID,omitempty"` + HasCustomTags bool `json:"hasCustomTags,omitempty"` } `json:"config"` Plugins map[string]PluginInfo `json:"plugins,omitempty"` } diff --git a/core/metrics/insights_internal_test.go b/core/metrics/insights_internal_test.go deleted file mode 100644 index 74c8ce236..000000000 --- a/core/metrics/insights_internal_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package metrics - -import ( - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("installedPackage", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) - }) - - It("returns empty when there's no .package file", func() { - Expect(installedPackage()).To(BeEmpty()) - }) - - It("reads the .package file from the data folder", func() { - writePackageFile("deb") - - Expect(installedPackage()).To(Equal("deb")) - }) - - It("trims surrounding whitespace, as the msi packager writes a trailing newline", func() { - writePackageFile("msi\n") - - Expect(installedPackage()).To(Equal("msi")) - }) - - It("ignores ND_PLATFORM", func() { - GinkgoT().Setenv("ND_PLATFORM", "zimaos") - - Expect(installedPackage()).To(BeEmpty()) - }) -}) - -var _ = Describe("hostingPlatform", func() { - BeforeEach(func() { - // Setenv registers the restore, then unset so an inherited value can't leak in - GinkgoT().Setenv("ND_PLATFORM", "") - Expect(os.Unsetenv("ND_PLATFORM")).To(Succeed()) - }) - - It("returns empty when ND_PLATFORM is not set", func() { - Expect(hostingPlatform()).To(BeEmpty()) - }) - - It("reads ND_PLATFORM", func() { - GinkgoT().Setenv("ND_PLATFORM", "zimaos") - - Expect(hostingPlatform()).To(Equal("zimaos")) - }) - - It("trims surrounding whitespace", func() { - GinkgoT().Setenv("ND_PLATFORM", " pikapods\n") - - Expect(hostingPlatform()).To(Equal("pikapods")) - }) -}) - -func writePackageFile(content string) { - GinkgoHelper() - path := filepath.Join(conf.Server.DataFolder.String(), ".package") - Expect(os.WriteFile(path, []byte(content), 0600)).To(Succeed()) -} diff --git a/core/metrics/metrics_suite_test.go b/core/metrics/metrics_suite_test.go deleted file mode 100644 index bae622e90..000000000 --- a/core/metrics/metrics_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package metrics - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestMetrics(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Metrics Suite") -} diff --git a/core/metrics/prometheus.go b/core/metrics/prometheus.go index c85706560..412483156 100644 --- a/core/metrics/prometheus.go +++ b/core/metrics/prometheus.go @@ -197,28 +197,28 @@ var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics { }) func processSqlAggregateMetrics(ctx context.Context, ds model.DataStore, targetGauge *prometheus.GaugeVec) { - albumsCount, err := ds.Album().CountAll(ctx) + albumsCount, err := ds.Album(ctx).CountAll() if err != nil { log.Warn("album CountAll error", err) return } targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount)) - artistCount, err := ds.Artist().CountAll(ctx) + artistCount, err := ds.Artist(ctx).CountAll() if err != nil { log.Warn("artist CountAll error", err) return } targetGauge.With(prometheus.Labels{"model": "artist"}).Set(float64(artistCount)) - songsCount, err := ds.MediaFile().CountAll(ctx) + songsCount, err := ds.MediaFile(ctx).CountAll() if err != nil { log.Warn("media CountAll error", err) return } targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount)) - usersCount, err := ds.User().CountAll(ctx) + usersCount, err := ds.User(ctx).CountAll() if err != nil { log.Warn("user CountAll error", err) return diff --git a/core/playback/device.go b/core/playback/device.go index 8e4e8880e..fd08b340e 100644 --- a/core/playback/device.go +++ b/core/playback/device.go @@ -23,7 +23,7 @@ type Track interface { } type playbackDevice struct { - serviceCtx context.Context //nolint:containedctx // playback service lifecycle ctx + serviceCtx context.Context ParentPlaybackServer PlaybackServer Default bool User string diff --git a/core/playback/mpv/mpv.go b/core/playback/mpv/mpv.go index 6696eca2a..035e18dd5 100644 --- a/core/playback/mpv/mpv.go +++ b/core/playback/mpv/mpv.go @@ -62,7 +62,8 @@ func (j *Executor) start(ctx context.Context) error { func (j *Executor) wait() { if err := j.cmd.Wait(); err != nil { - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { _ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())) } else { _ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err)) diff --git a/core/playback/mpv/track.go b/core/playback/mpv/track.go index 1038b9190..14170efd4 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 = 0 + var retries int = 0 for { fileInfo, err := os.Stat(path) diff --git a/core/playback/playbackserver.go b/core/playback/playbackserver.go index 48e3bcfaf..7dd02dcb1 100644 --- a/core/playback/playbackserver.go +++ b/core/playback/playbackserver.go @@ -111,7 +111,7 @@ func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) { // GetMediaFile retrieves the MediaFile given by the id parameter func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) { - return ps.datastore.MediaFile().Get(*ps.ctx, id) + return ps.datastore.MediaFile(*ps.ctx).Get(id) } // GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device. diff --git a/core/playback/queue.go b/core/playback/queue.go index 116db5fe6..d15eaad96 100644 --- a/core/playback/queue.go +++ b/core/playback/queue.go @@ -100,7 +100,6 @@ func (pd *Queue) Shuffle() { backupID = current.ID } - //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] }) var err error diff --git a/core/players.go b/core/players.go index 6fe86fd70..963914514 100644 --- a/core/players.go +++ b/core/players.go @@ -17,7 +17,6 @@ import ( type Players interface { Get(ctx context.Context, playerId string) (*model.Player, error) Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) - Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) } func NewPlayers(ds model.DataStore) Players { @@ -34,17 +33,18 @@ type players struct { func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) { var plr *model.Player + var trc *model.Transcoding var err error user, _ := request.UserFrom(ctx) if playerID != "" { - plr, err = p.ds.Player().Get(ctx, playerID) - if err == nil && (plr.Client != client || plr.UserId != user.ID) { + plr, err = p.ds.Player(ctx).Get(playerID) + if err == nil && plr.Client != client { playerID = "" } } username := userName(ctx) if err != nil || playerID == "" { - plr, err = p.ds.Player().FindMatch(ctx, user.ID, client, userAgent) + plr, err = p.ds.Player(ctx).FindMatch(user.ID, client, userAgent) if err == nil { log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", username, "type", userAgent) } else { @@ -58,21 +58,7 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", username, "type", userAgent) } } - if !plr.HasAPIKey { - plr.Name = fmt.Sprintf("%s [%s]", client, userAgent) - } - return p.refresh(ctx, plr, userAgent, ip) -} - -// Touch refreshes a player that the request already identified (by API key), without guessing or renaming it. -func (p *players) Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) { - if plr.Client == "" { - plr.Client = client - } - return p.refresh(ctx, &plr, userAgent, ip) -} - -func (p *players) refresh(ctx context.Context, plr *model.Player, userAgent, ip string) (*model.Player, *model.Transcoding, error) { + plr.Name = fmt.Sprintf("%s [%s]", client, userAgent) plr.UserAgent = userAgent plr.IP = ip plr.LastSeen = time.Now() @@ -80,17 +66,17 @@ func (p *players) refresh(ctx context.Context, plr *model.Player, userAgent, ip ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - if err := p.ds.Player().Put(ctx, plr); err != nil { - log.Warn(ctx, "Could not save player", "id", plr.ID, "client", plr.Client, "username", userName(ctx), "type", plr.UserAgent, err) + err = p.ds.Player(ctx).Put(plr) + if err != nil { + log.Warn(ctx, "Could not save player", "id", plr.ID, "client", client, "username", username, "type", userAgent, err) } }) - if plr.TranscodingId == "" { - return plr, nil, nil + if plr.TranscodingId != "" { + trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId) } - trc, err := p.ds.Transcoding().Get(ctx, plr.TranscodingId) return plr, trc, err } func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) { - return p.ds.Player().Get(ctx, playerId) + return p.ds.Player(ctx).Get(playerId) } diff --git a/core/players_test.go b/core/players_test.go index e452c52ba..90c265fcc 100644 --- a/core/players_test.go +++ b/core/players_test.go @@ -61,19 +61,8 @@ var _ = Describe("Players", func() { Expect(trc).To(BeNil()) }) - It("does not reuse another user's player by ID", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "otheruser", UserAgent: "Pixel", TranscodingId: "1"} - repo.add(plr) - p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") - Expect(err).ToNot(HaveOccurred()) - Expect(p.ID).ToNot(Equal("123")) - Expect(p.UserId).To(Equal("userid")) - Expect(repo.lastSaved).To(Equal(p)) - Expect(trc).To(BeNil()) - }) - It("finds players by ID", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}} repo.add(plr) p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) @@ -104,7 +93,7 @@ var _ = Describe("Players", func() { }) It("finds player by ID and return its transcoding", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}, TranscodingId: "1"} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"} repo.add(plr) p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) @@ -114,15 +103,6 @@ var _ = Describe("Players", func() { Expect(trc.ID).To(Equal("1")) }) - It("does not rename a player that has an API key", func() { - plr := &model.Player{ID: "123", Name: "My Phone", Client: "client", UserId: "userid", HasAPIKey: true} - repo.add(plr) - p, _, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") - Expect(err).ToNot(HaveOccurred()) - Expect(p.ID).To(Equal("123")) - Expect(p.Name).To(Equal("My Phone")) - }) - Context("bad username casing", func() { ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "Johndoe"}) @@ -139,34 +119,6 @@ var _ = Describe("Players", func() { }) }) }) - - Describe("Touch", func() { - It("records usage but keeps the name and client", func() { - plr := model.Player{ID: "123", Name: "My Phone", Client: "Symfonium", UserId: "userid", HasAPIKey: true} - p, trc, err := players.Touch(ctx, plr, "OtherClient", "android", "1.2.3.4") - Expect(err).ToNot(HaveOccurred()) - Expect(p.Name).To(Equal("My Phone")) - Expect(p.Client).To(Equal("Symfonium")) - Expect(p.UserAgent).To(Equal("android")) - Expect(p.IP).To(Equal("1.2.3.4")) - Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) - Expect(repo.lastSaved).To(Equal(p)) - Expect(trc).To(BeNil()) - }) - - It("fills in the client on first use", func() { - p, _, err := players.Touch(ctx, model.Player{ID: "123", Name: "Manual", UserId: "userid"}, "Symfonium", "android", "1.2.3.4") - Expect(err).ToNot(HaveOccurred()) - Expect(p.Client).To(Equal("Symfonium")) - }) - - It("returns the player's transcoding", func() { - p, trc, err := players.Touch(ctx, model.Player{ID: "123", UserId: "userid", TranscodingId: "1"}, "c", "ua", "1.2.3.4") - Expect(err).ToNot(HaveOccurred()) - Expect(p.ID).To(Equal("123")) - Expect(trc.ID).To(Equal("1")) - }) - }) }) type mockPlayerRepository struct { @@ -182,14 +134,14 @@ func (m *mockPlayerRepository) add(p *model.Player) { m.data[p.ID] = *p } -func (m *mockPlayerRepository) Get(_ context.Context, id string) (*model.Player, error) { +func (m *mockPlayerRepository) Get(id string) (*model.Player, error) { if p, ok := m.data[id]; ok { return &p, nil } return nil, model.ErrNotFound } -func (m *mockPlayerRepository) FindMatch(_ context.Context, userId, client, userAgent string) (*model.Player, error) { +func (m *mockPlayerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) { for _, p := range m.data { if p.Client == client && p.UserId == userId && p.UserAgent == userAgent { return &p, nil @@ -198,7 +150,7 @@ func (m *mockPlayerRepository) FindMatch(_ context.Context, userId, client, user return nil, model.ErrNotFound } -func (m *mockPlayerRepository) Put(_ context.Context, p *model.Player) error { +func (m *mockPlayerRepository) Put(p *model.Player) error { m.lastSaved = p return nil } diff --git a/core/playlists/import.go b/core/playlists/import.go index b5991b095..9d3ecabc5 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -8,14 +8,13 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/navidrome/navidrome/conf" "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/utils/ioutils" - "github.com/zeebo/xxh3" "golang.org/x/text/unicode/norm" ) @@ -39,7 +38,7 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo } if pls.ID != "" && pls.Sync != sync { pls.Sync = sync - if putErr := s.ds.Playlist().Put(ctx, pls); putErr != nil { + if putErr := s.ds.Playlist(ctx).Put(pls); putErr != nil { return nil, putErr } } @@ -59,12 +58,10 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo } defer file.Close() - hasher := xxh3.New() - reader := io.TeeReader(ioutils.UTF8Reader(file), hasher) + reader := ioutils.UTF8Reader(file) if err := s.parseM3U(ctx, pls, nil, reader); err != nil { return nil, err } - pls.ImportedHash = fingerprint(hasher) if err := s.updatePlaylist(ctx, pls, sync); err != nil { return nil, err } @@ -74,17 +71,17 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo var errNotInLibrary = fmt.Errorf("path not in any library") func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folder, error) { - libs, err := s.ds.Library().GetAll(ctx) + libs, err := s.ds.Library(ctx).GetAll() if err != nil { return nil, err } - matcher := model.NewLibraryMatcher(libs) - lib, ok := matcher.FindLibrary(dir) + matcher := newLibraryMatcher(libs) + lib, ok := matcher.findLibrary(dir) if !ok { return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir) } - folder, err := s.ds.Folder().GetByPath(ctx, lib, dir) + folder, err := s.ds.Folder(ctx).GetByPath(lib, dir) if err != nil { return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err) } @@ -122,7 +119,7 @@ func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Pla log.Error(ctx, "Error parsing playlist", err) return nil, err } - err = s.ds.Playlist().Put(ctx, pls) + err = s.ds.Playlist(ctx).Put(pls) if err != nil { log.Error(ctx, "Error saving playlist", err) return nil, err @@ -142,9 +139,7 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold } defer file.Close() - // Hash the bytes the parser consumes, giving every imported playlist a content fingerprint - hasher := xxh3.New() - reader := io.TeeReader(ioutils.UTF8Reader(file), hasher) + reader := ioutils.UTF8Reader(file) extension := strings.ToLower(filepath.Ext(playlistFile)) switch extension { case ".nsp": @@ -152,28 +147,20 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold default: err = s.parseM3U(ctx, pls, folder, reader) } - if err != nil { - return pls, err - } - pls.ImportedHash = fingerprint(hasher) - return pls, nil -} - -func fingerprint(h *xxh3.Hasher) string { - return id.Encode(h.Sum128().Bytes()) + return pls, err } // findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode // normalization forms to handle cross-platform filesystem differences. func (s *playlists) findByPathNormalized(ctx context.Context, path string) (*model.Playlist, error) { - pls, err := s.ds.Playlist().FindByPath(ctx, path) + pls, err := s.ds.Playlist(ctx).FindByPath(path) if errors.Is(err, model.ErrNotFound) { altPath := norm.NFD.String(path) if altPath == path { altPath = norm.NFC.String(path) } if altPath != path { - pls, err = s.ds.Playlist().FindByPath(ctx, altPath) + pls, err = s.ds.Playlist(ctx).FindByPath(altPath) } } return pls, err @@ -193,12 +180,6 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, } if err == nil { - // Only smart playlists skip on an unchanged file; M3U must re-run so newly-added tracks resolve. - if !forceSync && newPls.IsSmartPlaylist() && newPls.ImportedHash != "" && newPls.ImportedHash == pls.ImportedHash { - log.Trace(ctx, "Playlist file unchanged since last import, skipping", "playlist", pls.Name, "path", pls.Path) - *newPls = *pls // callers must see the stored record, so e.g. ImportFile can still flip Sync - return nil - } log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path) newPls.ID = pls.ID newPls.Name = pls.Name @@ -206,13 +187,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, newPls.OwnerID = pls.OwnerID newPls.Public = pls.Public newPls.UploadedImage = pls.UploadedImage // Preserve manual upload - newPls.EvaluatedAt = nil // force re-evaluation on next read - if newPls.IsSmartPlaylist() { - // Tracks aren't materialized at parse time; carry the stored counters so callers see real values - newPls.SongCount = pls.SongCount - newPls.Duration = pls.Duration - newPls.Size = pls.Size - } + newPls.EvaluatedAt = &time.Time{} } else { log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) newPls.OwnerID = owner.ID @@ -221,5 +196,5 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, newPls.Public = conf.Server.DefaultPlaylistPublicVisibility } } - return s.ds.Playlist().Put(ctx, newPls) + return s.ds.Playlist(ctx).Put(newPls) } diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 25960f0fe..f2866fb60 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -11,16 +11,14 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" - "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" - "github.com/zeebo/xxh3" "golang.org/x/text/unicode/norm" ) @@ -45,7 +43,7 @@ var _ = Describe("Playlists - Import", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) ds.MockedMediaFile = &mockedMediaFileRepo{} libPath, _ := os.Getwd() // Set up library with the actual library path that matches the folder @@ -120,7 +118,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -138,7 +136,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -157,7 +155,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -176,7 +174,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -194,7 +192,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -211,7 +209,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -228,25 +226,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(BeEmpty()) - }) - - It("rejects #EXTALBUMARTURL pointing at a non-image file inside the library", func() { - tmpDir := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(tmpDir, "config.ini"), []byte("password=secret"), 0600)).To(Succeed()) - - m3u := "#EXTALBUMARTURL:config.ini\ntest.mp3\n" - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -264,7 +244,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -278,7 +258,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -305,7 +285,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -327,38 +307,11 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ID).To(BeEmpty()) }) - It("stores a content hash but re-imports unchanged M3U playlists", func() { - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - - first, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(first.ImportedHash).ToNot(BeEmpty()) - - // Re-import with a matching stored hash: M3U must still be re-imported, not skipped. - existingPls := &model.Playlist{ - ID: "m3u-id", Name: "Test", Path: plsFile, Sync: true, - OwnerID: "123", ImportedHash: first.ImportedHash, - } - mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} - mockPlsRepo.Last = nil - - _, err = ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last).ToNot(BeNil()) - }) - It("clears ExternalImageURL on re-scan when directive is removed", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) m3u := "test.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -418,85 +371,6 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.Name).To(Equal("Recently Played")) Expect(pls.Public).To(BeTrue()) // Should be true since server default is true }) - - It("preserves counters when re-importing an existing smart playlist", func() { - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}],"sort":"title","order":"asc"}` - plsFile := filepath.Join(tmpDir, "smart.nsp") - Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) - - existingPls := &model.Playlist{ - ID: "smart-id", - Name: "My Smart", - Path: plsFile, - Sync: true, - OwnerID: "123", - SongCount: 42, - Duration: 123.4, - Size: 5000, - } - mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") - - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last).ToNot(BeNil()) - Expect(mockPlsRepo.Last.IsSmartPlaylist()).To(BeTrue()) - Expect(mockPlsRepo.Last.SongCount).To(Equal(42)) - Expect(mockPlsRepo.Last.Duration).To(Equal(float32(123.4))) - Expect(mockPlsRepo.Last.Size).To(Equal(int64(5000))) - }) - - It("skips re-import when the smart playlist file content is unchanged", func() { - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` - plsFile := filepath.Join(tmpDir, "smart.nsp") - Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) - - existingPls := &model.Playlist{ - ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, - OwnerID: "123", SongCount: 42, - ImportedHash: hashOf(nsp), - } - mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") - - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last).To(BeNil()) // Put never called: nothing re-written - }) - - It("re-imports when the smart playlist file content changed", func() { - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` - plsFile := filepath.Join(tmpDir, "smart.nsp") - Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) - - existingPls := &model.Playlist{ - ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, - OwnerID: "123", SongCount: 42, - ImportedHash: hashOf("old content"), - } - mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") - - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last).ToNot(BeNil()) // Put called: file changed - Expect(mockPlsRepo.Last.ImportedHash).To(Equal(hashOf(nsp))) - }) }) DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)", @@ -514,7 +388,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) // Create the playlist file on disk with the filesystem's normalization form plsFile := tmpDir + "/" + filesystemName + ".m3u" @@ -574,7 +448,7 @@ var _ = Describe("Playlists - Import", func() { "def.mp3", // This is playlists/def.mp3 relative to plsDir }, } - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("handles relative paths that reference files in other libraries", func() { @@ -730,7 +604,7 @@ var _ = Describe("Playlists - Import", func() { }, } // Recreate playlists service to pick up new mock - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) // Create playlist in music library that references both tracks plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3" @@ -788,7 +662,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n" plsFile := filepath.Join(tmpDir, "my-playlist.m3u") @@ -807,7 +681,7 @@ var _ = Describe("Playlists - Import", func() { libDir := filepath.Join(tmpDir, "music") Expect(os.Mkdir(libDir, 0755)).To(Succeed()) mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}}) - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n" plsFile := filepath.Join(tmpDir, "external.m3u") @@ -830,7 +704,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -850,7 +724,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -870,7 +744,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -886,37 +760,6 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ID).To(Equal("existing-id")) Expect(pls.Sync).To(BeTrue()) }) - - It("unsyncs a synced smart playlist with sync=false even when content is unchanged", func() { - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - - mockFolderRepo := &mockFolderRepoForImport{ - folder: &model.Folder{ - ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", - }, - } - ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` - plsFile := filepath.Join(tmpDir, "smart.nsp") - Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) - - existingPls := &model.Playlist{ - ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, - OwnerID: "123", SongCount: 42, - ImportedHash: hashOf(nsp), - } - mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} - - pls, err := ps.ImportFile(ctx, plsFile, false) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ID).To(Equal("smart-id")) - Expect(pls.Sync).To(BeFalse()) - Expect(mockPlsRepo.Last).ToNot(BeNil()) - Expect(mockPlsRepo.Last.Sync).To(BeFalse()) - }) }) Describe("ImportM3U", func() { @@ -924,7 +767,7 @@ var _ = Describe("Playlists - Import", func() { BeforeEach(func() { repo = &mockedMediaFileFromListRepo{} ds.MockedMediaFile = repo - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}}) ctx = request.WithUser(ctx, model.User{ID: "123"}) }) @@ -1029,19 +872,6 @@ var _ = Describe("Playlists - Import", func() { Expect(pls.ExternalImageURL).To(BeEmpty()) }) - DescribeTable("restricts a local #EXTALBUMARTURL to the owner's libraries", - func(imageURL, expected string) { - ctx = request.WithUser(ctx, model.User{ID: "123", Libraries: model.Libraries{{ID: 1, Path: "/music"}}}) - repo.data = []string{"tests/test.mp3"} - m3u := "#EXTALBUMARTURL:" + imageURL + "\n/music/tests/test.mp3\n" - pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(Equal(expected)) - }, - Entry("accepts a library the owner can access", "file:///music/cover.jpg", filepath.Clean("/music/cover.jpg")), - Entry("ignores a library the owner cannot access", "file:///new/cover.jpg", ""), - ) - // Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation, // so we need exact matching for non-ASCII characters. It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() { @@ -1172,7 +1002,7 @@ type mockedMediaFileRepo struct { data map[string]model.MediaFile } -func (r *mockedMediaFileRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) { +func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) { var mfs model.MediaFiles // If data map provided, look up files @@ -1212,7 +1042,7 @@ type mockedMediaFileFromListRepo struct { data []string } -func (r *mockedMediaFileFromListRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) { +func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) { var mfs model.MediaFiles for idx, dataPath := range r.data { @@ -1247,13 +1077,9 @@ type mockFolderRepoForImport struct { folder *model.Folder } -func (m *mockFolderRepoForImport) GetByPath(_ context.Context, _ model.Library, _ string) (*model.Folder, error) { +func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) { if m.folder != nil { return m.folder, nil } return nil, model.ErrNotFound } - -func hashOf(content string) string { - return id.Encode(xxh3.Hash128([]byte(content)).Bytes()) -} diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index ab95b8850..a64c337c9 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -1,31 +1,32 @@ package playlists import ( + "cmp" "context" "fmt" "io" "net/url" "path/filepath" + "slices" "strings" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/slice" "golang.org/x/text/unicode/norm" ) func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error { - mediaFileRepository := s.ds.MediaFile() + mediaFileRepository := s.ds.MediaFile(ctx) resolver, err := newPathResolver(ctx, s.ds) if err != nil { return err } var mfs model.MediaFiles - // Chunked so a huge playlist is not held in memory at once. Each line yields up to - // 4 lookup candidates (NFC/NFD × raw/lowercase), far below SQLite's 32766 variables. + // Chunk size of 100 lines, as each line can generate up to 4 lookup candidates + // (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000. for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) { filteredLines := make([]string, 0, len(lines)) for _, line := range lines { @@ -35,8 +36,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m continue } if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok { - owner, _ := request.UserFrom(ctx) - pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher, owner) + pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher) continue } // Skip empty lines and extended info @@ -94,7 +94,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m } } - found, err := mediaFileRepository.FindByPaths(ctx, lookupCandidates) + found, err := mediaFileRepository.FindByPaths(lookupCandidates) if err != nil { log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err) continue @@ -154,18 +154,70 @@ func (r pathResolution) ToQualifiedString() (string, error) { return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil } +// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching. +type libraryMatcher struct { + libraries model.Libraries + cleanedPaths []string +} + +// findLibraryForPath finds which library contains the given absolute path. +// Returns library ID and path, or 0 and empty string if not found. +func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) { + lib, ok := lm.findLibrary(absolutePath) + if !ok { + return 0, "" + } + return lib.ID, filepath.Clean(lib.Path) +} + +// findLibrary checks if the absolute path is under any of the library paths. +func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) { + // Check sorted libraries (longest path first) to find the best match + for i, cleanLibPath := range lm.cleanedPaths { + // Check if absolutePath is under this library path + if strings.HasPrefix(absolutePath, cleanLibPath) { + // Ensure it's a proper path boundary (not just a prefix) + if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator { + return lm.libraries[i], true + } + } + } + return model.Library{}, false +} + +// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first). +// This ensures correct matching when library paths are prefixes of each other. +// Example: /music-classical must be checked before /music +// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical +func newLibraryMatcher(libs model.Libraries) *libraryMatcher { + // Sort libraries by path length (descending) to ensure longest paths match first. + slices.SortFunc(libs, func(i, j model.Library) int { + return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending + }) + + // Pre-clean all library paths once for efficient matching + cleanedPaths := make([]string, len(libs)) + for i, lib := range libs { + cleanedPaths[i] = filepath.Clean(lib.Path) + } + return &libraryMatcher{ + libraries: libs, + cleanedPaths: cleanedPaths, + } +} + // pathResolver handles path resolution logic for playlist imports. type pathResolver struct { - matcher *model.LibraryMatcher + matcher *libraryMatcher } // newPathResolver creates a pathResolver with libraries loaded from the datastore. func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) { - libs, err := ds.Library().GetAll(ctx) + libs, err := ds.Library(ctx).GetAll() if err != nil { return nil, err } - matcher := model.NewLibraryMatcher(libs) + matcher := newLibraryMatcher(libs) return &pathResolver{matcher: matcher}, nil } @@ -192,14 +244,14 @@ func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolu // a pathResolution with the library information. Returns an invalid resolution if // the path is not found in any library. func (r *pathResolver) findInLibraries(absolutePath string) pathResolution { - lib, ok := r.matcher.FindLibrary(absolutePath) - if !ok { + libID, libPath := r.matcher.findLibraryForPath(absolutePath) + if libID == 0 { return pathResolution{valid: false} } return pathResolution{ absolutePath: absolutePath, - libraryPath: filepath.Clean(lib.Path), - libraryID: lib.ID, + libraryPath: libPath, + libraryID: libID, valid: true, } } @@ -234,7 +286,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l // HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt). // Local paths (file://, absolute, or relative) are resolved to an absolute path // and validated against known library boundaries via matcher. -func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryMatcher, owner model.User) string { +func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string { value = strings.TrimSpace(value) if value == "" { return "" @@ -250,13 +302,12 @@ func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryM // Resolve to local absolute path localPath, ok := resolveLocalPath(value, folder) - if !ok || !model.IsImageFile(localPath) { + if !ok { return "" } - lib, ok := matcher.FindLibrary(localPath) - // A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries. - if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) { + // Validate path is within a known library + if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 { return "" } return localPath diff --git a/core/playlists/parse_m3u_test.go b/core/playlists/parse_m3u_test.go index ced6c2b16..d7fd5e001 100644 --- a/core/playlists/parse_m3u_test.go +++ b/core/playlists/parse_m3u_test.go @@ -9,6 +9,187 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("libraryMatcher", func() { + var ds *tests.MockDataStore + var mockLibRepo *tests.MockLibraryRepo + ctx := context.Background() + + BeforeEach(func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)") + mockLibRepo = &tests.MockLibraryRepo{} + ds = &tests.MockDataStore{ + MockedLibrary: mockLibRepo, + } + }) + + // Helper function to create a libraryMatcher from the mock datastore + createMatcher := func(ds model.DataStore) *libraryMatcher { + libs, err := ds.Library(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + return newLibraryMatcher(libs) + } + + Describe("Longest library path matching", func() { + It("matches the longest library path when multiple libraries share a prefix", func() { + // Setup libraries with prefix conflicts + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/music"}, + {ID: 2, Path: "/music-classical"}, + {ID: 3, Path: "/music-classical/opera"}, + }) + + matcher := createMatcher(ds) + + // Test that longest path matches first and returns correct library ID + testCases := []struct { + path string + expectedLibID int + expectedLibPath string + }{ + {"/music-classical/opera/track.mp3", 3, "/music-classical/opera"}, + {"/music-classical/track.mp3", 2, "/music-classical"}, + {"/music/track.mp3", 1, "/music"}, + {"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"}, + } + + for _, tc := range testCases { + libID, libPath := matcher.findLibraryForPath(tc.path) + Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID) + Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath) + } + }) + + It("handles libraries with similar prefixes but different structures", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/home/user/music"}, + {ID: 2, Path: "/home/user/music-backup"}, + }) + + matcher := createMatcher(ds) + + // Test that music-backup library is matched correctly + libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3") + Expect(libID).To(Equal(2)) + Expect(libPath).To(Equal("/home/user/music-backup")) + + // Test that music library is still matched correctly + libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3") + Expect(libID).To(Equal(1)) + Expect(libPath).To(Equal("/home/user/music")) + }) + + It("matches path that is exactly the library root", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/music"}, + {ID: 2, Path: "/music-classical"}, + }) + + matcher := createMatcher(ds) + + // Exact library path should match + libID, libPath := matcher.findLibraryForPath("/music-classical") + Expect(libID).To(Equal(2)) + Expect(libPath).To(Equal("/music-classical")) + }) + + It("handles complex nested library structures", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/media"}, + {ID: 2, Path: "/media/audio"}, + {ID: 3, Path: "/media/audio/classical"}, + {ID: 4, Path: "/media/audio/classical/baroque"}, + }) + + matcher := createMatcher(ds) + + testCases := []struct { + path string + expectedLibID int + expectedLibPath string + }{ + {"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"}, + {"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"}, + {"/media/audio/rock/track.mp3", 2, "/media/audio"}, + {"/media/video/movie.mp4", 1, "/media"}, + } + + for _, tc := range testCases { + libID, libPath := matcher.findLibraryForPath(tc.path) + Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) + Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath) + } + }) + }) + + Describe("Edge cases", func() { + It("handles empty library list", func() { + mockLibRepo.SetData([]model.Library{}) + + matcher := createMatcher(ds) + Expect(matcher).ToNot(BeNil()) + + // Should not match anything + libID, libPath := matcher.findLibraryForPath("/music/track.mp3") + Expect(libID).To(Equal(0)) + Expect(libPath).To(BeEmpty()) + }) + + It("handles single library", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/music"}, + }) + + matcher := createMatcher(ds) + + libID, libPath := matcher.findLibraryForPath("/music/track.mp3") + Expect(libID).To(Equal(1)) + Expect(libPath).To(Equal("/music")) + }) + + It("handles libraries with special characters in paths", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/music[test]"}, + {ID: 2, Path: "/music(backup)"}, + }) + + matcher := createMatcher(ds) + Expect(matcher).ToNot(BeNil()) + + // Special characters should match literally + libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3") + Expect(libID).To(Equal(1)) + Expect(libPath).To(Equal("/music[test]")) + }) + }) + + Describe("Path matching order", func() { + It("ensures longest paths match first", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/a"}, + {ID: 2, Path: "/ab"}, + {ID: 3, Path: "/abc"}, + }) + + matcher := createMatcher(ds) + + // Verify that longer paths match correctly (not cut off by shorter prefix) + testCases := []struct { + path string + expectedLibID int + }{ + {"/abc/file.mp3", 3}, + {"/ab/file.mp3", 2}, + {"/a/file.mp3", 1}, + } + + for _, tc := range testCases { + libID, _ := matcher.findLibraryForPath(tc.path) + Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) + } + }) + }) +}) + var _ = Describe("pathResolver", func() { var ds *tests.MockDataStore var mockLibRepo *tests.MockLibraryRepo diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go index a5b8b7c02..56c80a950 100644 --- a/core/playlists/parse_nsp.go +++ b/core/playlists/parse_nsp.go @@ -59,7 +59,8 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R } err = json.Unmarshal(input, nsp) if err != nil { - if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok { + var syntaxErr *json.SyntaxError + if errors.As(err, &syntaxErr) { 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 d6d69866f..516a5355d 100644 --- a/core/playlists/parse_nsp_test.go +++ b/core/playlists/parse_nsp_test.go @@ -113,20 +113,6 @@ 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.go b/core/playlists/playlists.go index c9bc03b97..3da24706c 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -22,7 +22,6 @@ type Playlists interface { GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) Get(ctx context.Context, id string) (*model.Playlist, error) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) - Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) // Mutations @@ -32,7 +31,6 @@ type Playlists interface { // Track management AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) - InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) @@ -49,28 +47,24 @@ type Playlists interface { ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) // REST adapters - Repository() rest.Repository[model.Playlist] - TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository[model.PlaylistTrack] + NewRepository(ctx context.Context) rest.Repository + TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository } -// ImageUploadService is a local interface satisfied by artwork.Uploader. -// Defined here to avoid an import cycle between core/artwork and core/playlists. +// ImageUploadService is a local interface satisfied by core.ImageUploadService. +// Defined here to avoid an import cycle between core and core/playlists. type ImageUploadService interface { SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) RemoveImage(ctx context.Context, path string) error - EnqueueArtwork(ctx context.Context, entityType, entityID string) } type playlists struct { ds model.DataStore imgUpload ImageUploadService - repo *playlistRepositoryWrapper } func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists { - s := &playlists{ds: ds, imgUpload: imgUpload} - s.repo = &playlistRepositoryWrapper{PlaylistRepository: ds.Playlist(), service: s} - return s + return &playlists{ds: ds, imgUpload: imgUpload} } func InPath(folder model.Folder) bool { @@ -89,34 +83,19 @@ func InPath(folder model.Folder) bool { // --- Read operations --- func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) { - return s.ds.Playlist().GetAll(ctx, options...) + return s.ds.Playlist(ctx).GetAll(options...) } func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) { - return s.ds.Playlist().Get(ctx, id) + return s.ds.Playlist(ctx).Get(id) } func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) { - return s.ds.Playlist().GetWithTracks(ctx, id, true, false) + return s.ds.Playlist(ctx).GetWithTracks(id, true, false) } func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) { - return s.ds.Playlist().GetPlaylists(ctx, mediaFileId) -} - -// Tracks scopes a repository to one playlist's tracks, for callers that page or stream them rather -// than loading every one like GetWithTracks. Gets first because PlaylistRepository.Tracks discards -// its error behind a nil (and warns), and this is probed with ids that are usually not playlists. -func (s *playlists) Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) { - repo := s.ds.Playlist() - if _, err := repo.Get(ctx, id); err != nil { - return nil, err - } - tracks := repo.Tracks(ctx, id, true) - if tracks == nil { - return nil, model.ErrNotFound - } - return tracks, nil + return s.ds.Playlist(ctx).GetPlaylists(mediaFileId) } // --- Mutation operations --- @@ -130,16 +109,15 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string, var err error if playlistId != "" { - pls, err = tx.Playlist().Get(ctx, playlistId) + pls, err = tx.Playlist(ctx).Get(playlistId) if err != nil { return err } - // Ownership first: a non-owner must get ErrNotAuthorized, not a read-only conflict. - if !usr.IsAdmin && pls.OwnerID != usr.ID { + if pls.IsSmartPlaylist() { return model.ErrNotAuthorized } - if !pls.TracksEditable() { - return model.ErrPlaylistNotEditable + if !usr.IsAdmin && pls.OwnerID != usr.ID { + return model.ErrNotAuthorized } } else { pls = &model.Playlist{Name: name} @@ -148,7 +126,7 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string, pls.Tracks = nil pls.AddMediaFilesByID(ids) - err = tx.Playlist().Put(ctx, pls) + err = tx.Playlist(ctx).Put(pls) playlistId = pls.ID return err }) @@ -168,7 +146,7 @@ func (s *playlists) Delete(ctx context.Context, id string) error { } } - return s.ds.Playlist().Delete(ctx, id) + return s.ds.Playlist(ctx).Delete(id) } func (s *playlists) Update(ctx context.Context, playlistID string, @@ -186,21 +164,21 @@ func (s *playlists) Update(ctx context.Context, playlistID string, return err } return s.ds.WithTxImmediate(func(tx model.DataStore) error { - repo := tx.Playlist() + repo := tx.Playlist(ctx) if len(idxToRemove) > 0 { - tracksRepo := repo.Tracks(ctx, playlistID, false) + tracksRepo := repo.Tracks(playlistID, false) // Convert 0-based indices to 1-based position IDs and delete them directly, // avoiding the need to load all tracks into memory. positions := make([]string, len(idxToRemove)) for i, idx := range idxToRemove { positions[i] = strconv.Itoa(idx + 1) } - if err := tracksRepo.Delete(ctx, positions...); err != nil { + if err := tracksRepo.Delete(positions...); err != nil { return err } if len(idsToAdd) > 0 { - if _, err := tracksRepo.Add(ctx, idsToAdd); err != nil { + if _, err := tracksRepo.Add(idsToAdd); err != nil { return err } } @@ -208,7 +186,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string, } if len(idsToAdd) > 0 { - if _, err := repo.Tracks(ctx, playlistID, false).Add(ctx, idsToAdd); err != nil { + if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil { return err } } @@ -224,7 +202,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string, // checkWritable fetches the playlist and verifies the current user can modify it. func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) { - pls, err := s.ds.Playlist().Get(ctx, id) + pls, err := s.ds.Playlist(ctx).Get(id) if err != nil { return nil, err } @@ -235,14 +213,14 @@ func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playli return pls, nil } -// checkTracksEditable verifies the user owns the playlist and its tracks are editable. +// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist). func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) { pls, err := s.checkWritable(ctx, playlistID) if err != nil { return nil, err } - if !pls.TracksEditable() { - return nil, model.ErrPlaylistNotEditable + if pls.IsSmartPlaylist() { + return nil, model.ErrNotAuthorized } return pls, nil } @@ -260,7 +238,7 @@ func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls if public != nil { pls.Public = *public } - return ds.Playlist().Put(ctx, pls) + return ds.Playlist(ctx).Put(pls) } // --- Track management operations --- @@ -269,44 +247,28 @@ func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []stri if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return 0, err } - return s.ds.Playlist().Tracks(ctx, playlistID, false).Add(ctx, ids) -} - -// InsertTracks adds tracks before the 1-based position pos; a position past the end appends. -func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) { - if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { - return 0, err - } - var count int - // Immediate: a deferred tx that reads before shifting fails at once with SQLITE_BUSY under - // concurrent writers instead of waiting for the lock. - err := s.ds.WithTxImmediate(func(tx model.DataStore) error { - var err error - count, err = tx.Playlist().Tracks(ctx, playlistID, false).Insert(ctx, ids, pos) - return err - }) - return count, err + return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids) } func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) { if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return 0, err } - return s.ds.Playlist().Tracks(ctx, playlistID, false).AddAlbums(ctx, albumIds) + return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds) } func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) { if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return 0, err } - return s.ds.Playlist().Tracks(ctx, playlistID, false).AddArtists(ctx, artistIds) + return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds) } func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) { if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return 0, err } - return s.ds.Playlist().Tracks(ctx, playlistID, false).AddDiscs(ctx, discs) + return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs) } func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error { @@ -314,7 +276,7 @@ func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackId return err } return s.ds.WithTx(func(tx model.DataStore) error { - return tx.Playlist().Tracks(ctx, playlistID, false).Delete(ctx, trackIds...) + return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...) }) } @@ -322,8 +284,8 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { return err } - return s.ds.WithTxImmediate(func(tx model.DataStore) error { - return tx.Playlist().Tracks(ctx, playlistID, false).Reorder(ctx, pos, newPos) + return s.ds.WithTx(func(tx model.DataStore) error { + return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos) }) } @@ -342,11 +304,7 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R } pls.UploadedImage = filename - if err := s.ds.Playlist().Put(ctx, pls); err != nil { - return err - } - s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) - return nil + return s.ds.Playlist(ctx).Put(pls) } func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { @@ -360,9 +318,5 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { } pls.UploadedImage = "" - if err := s.ds.Playlist().Put(ctx, pls); err != nil { - return err - } - s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) - return nil + return s.ds.Playlist(ctx).Put(pls) } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index ec44de3bc..52d5c88d8 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -8,7 +8,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -42,7 +42,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to delete their playlist", func() { @@ -73,28 +73,6 @@ var _ = Describe("Playlists", func() { }) }) - Describe("Tracks", func() { - var mockTracks *tests.MockPlaylistTrackRepo - - BeforeEach(func() { - mockTracks = &tests.MockPlaylistTrackRepo{} - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, - } - mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("returns the playlist's track repository", func() { - Expect(ps.Tracks(ctx, "pls-1")).To(BeIdenticalTo(mockTracks)) - }) - - It("returns ErrNotFound for an unknown or invisible playlist", func() { - _, err := ps.Tracks(ctx, "nonexistent") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - Describe("Create", func() { BeforeEach(func() { mockPlsRepo.Data = map[string]*model.Playlist{ @@ -102,10 +80,8 @@ var _ = Describe("Playlists", func() { "pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, - "pls-synced-other": {ID: "pls-synced-other", Name: "Other's Synced", OwnerID: "other-user", Sync: true, Public: true}, } - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("creates a new playlist with owner set from context", func() { @@ -147,18 +123,6 @@ var _ = Describe("Playlists", func() { It("denies replacing tracks on a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) _, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("denies replacing tracks on a synced playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.Create(ctx, "pls-synced", "", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("denies a non-owner with authorization, not a conflict, on a public synced playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.Create(ctx, "pls-synced-other", "", []string{"song-1"}) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) }) @@ -173,63 +137,55 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to update their playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) + newName := "Updated Name" + err := ps.Update(ctx, "pls-1", &newName, 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}) - err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil) + newName := "Updated Name" + err := ps.Update(ctx, "pls-other", &newName, 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}) - err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) + newName := "Updated Name" + err := ps.Update(ctx, "pls-1", &newName, 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}) - err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil) + newName := "Updated Name" + err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil) Expect(err).To(Equal(model.ErrNotFound)) }) It("denies adding tracks to a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + Expect(err).To(MatchError(model.ErrNotAuthorized)) }) It("denies removing tracks from a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + Expect(err).To(MatchError(model.ErrNotAuthorized)) }) It("allows metadata updates on a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil) - Expect(err).ToNot(HaveOccurred()) - }) - - It("denies adding tracks to a synced playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Update(ctx, "pls-synced", nil, nil, nil, []string{"song-1"}, nil) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("allows metadata updates on a synced playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Update(ctx, "pls-synced", new("Renamed Synced"), nil, nil, nil, nil) + newName := "Updated Smart" + err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) }) @@ -243,11 +199,10 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, - "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, + "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to add tracks", func() { @@ -274,13 +229,7 @@ var _ = Describe("Playlists", func() { It("denies editing smart playlists", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) _, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("denies editing synced playlists", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.AddTracks(ctx, "pls-synced", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + Expect(err).To(MatchError(model.ErrNotAuthorized)) }) It("returns error when playlist not found", func() { @@ -301,7 +250,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to remove tracks", func() { @@ -314,7 +263,7 @@ var _ = Describe("Playlists", func() { It("denies on smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + Expect(err).To(MatchError(model.ErrNotAuthorized)) }) It("denies non-owner", func() { @@ -324,43 +273,6 @@ var _ = Describe("Playlists", func() { }) }) - Describe("InsertTracks", func() { - var mockTracks *tests.MockPlaylistTrackRepo - - BeforeEach(func() { - mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2} - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, - "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - } - mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("inserts the tracks at the given position for the owner", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - count, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1", "song-2"}, 3) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(2)) - Expect(mockTracks.AddedIds).To(Equal([]string{"song-1", "song-2"})) - Expect(mockTracks.InsertPos).To(Equal(3)) - }) - - It("denies non-owner, non-admin", func() { - ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - _, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1"}, 1) - Expect(err).To(MatchError(model.ErrNotAuthorized)) - Expect(mockTracks.AddedIds).To(BeEmpty()) - }) - - It("denies editing smart playlists", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.InsertTracks(ctx, "pls-smart", []string{"song-1"}, 1) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - }) - Describe("ReorderTrack", func() { var mockTracks *tests.MockPlaylistTrackRepo @@ -372,7 +284,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to reorder", func() { @@ -385,7 +297,7 @@ var _ = Describe("Playlists", func() { It("denies on smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.ReorderTrack(ctx, "pls-smart", 1, 3) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + Expect(err).To(MatchError(model.ErrNotAuthorized)) }) }) @@ -395,13 +307,13 @@ var _ = Describe("Playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) + conf.Server.DataFolder = tmpDir mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("saves image file and updates UploadedImage", func() { @@ -459,7 +371,7 @@ var _ = Describe("Playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) + conf.Server.DataFolder = tmpDir // Create a real image file on disk imgDir := filepath.Join(tmpDir, "artwork", "playlist") @@ -471,7 +383,7 @@ var _ = Describe("Playlists", func() { "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("removes file and clears UploadedImage", func() { @@ -491,24 +403,6 @@ var _ = Describe("Playlists", func() { Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty()) }) - It("clears the resolved artwork state and re-queues after removing an upload", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "pl", ItemID: "pls-1", Hash: "oldhash", Source: "upload", - })).To(Succeed()) - - Expect(ps.RemoveImage(ctx, "pls-1")).To(Succeed()) - - _, err := ds.Artwork().GetItemArtwork(ctx, model.KindPlaylistArtwork, "pls-1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - queued, _ := ds.ArtworkQueue().DequeueBatch(ctx, 100) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "pl"), - HaveField("ItemID", "pls-1"), - HaveField("Priority", model.ArtworkPriorityBump), - ))) - }) - It("denies non-owner", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) err := ps.RemoveImage(ctx, "pls-1") diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index f8e8a9d41..c9b7c4ea6 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -2,55 +2,61 @@ package playlists 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) --- -func (s *playlists) Repository() rest.Repository[model.Playlist] { - return s.repo +func (s *playlists) NewRepository(ctx context.Context) rest.Repository { + return &playlistRepositoryWrapper{ + ctx: ctx, + PlaylistRepository: s.ds.Playlist(ctx), + service: s, + } } -// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter, -// delegating to service methods for all mutations. +// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter. +// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository), +// and rest.Persistable by delegating to service methods for all mutations. type playlistRepositoryWrapper struct { model.PlaylistRepository + ctx context.Context service *playlists } -var _ rest.Persistable[model.Playlist] = (*playlistRepositoryWrapper)(nil) - -func (r *playlistRepositoryWrapper) Save(ctx context.Context, entity *model.Playlist) (string, error) { - return r.service.savePlaylist(ctx, entity) +func (r *playlistRepositoryWrapper) Save(entity any) (string, error) { + return r.service.savePlaylist(r.ctx, entity.(*model.Playlist)) } -func (r *playlistRepositoryWrapper) Update(ctx context.Context, id string, entity model.Playlist, cols ...string) error { - return r.service.updatePlaylistEntity(ctx, id, &entity, cols...) +func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error { + return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist)) } -func (r *playlistRepositoryWrapper) Delete(ctx context.Context, ids ...string) error { - for _, id := range ids { - if err := r.service.Delete(ctx, id); err != nil { - return err - } +func (r *playlistRepositoryWrapper) Delete(id string) error { + err := r.service.Delete(r.ctx, id) + switch { + case errors.Is(err, model.ErrNotFound): + return rest.ErrNotFound + case errors.Is(err, model.ErrNotAuthorized): + return rest.ErrPermissionDenied + default: + return err } - return nil } -func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository[model.PlaylistTrack] { - repo := s.ds.Playlist() - tracks := repo.Tracks(ctx, playlistId, refreshSmartPlaylist) +func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository { + repo := s.ds.Playlist(ctx) + tracks := repo.Tracks(playlistId, refreshSmartPlaylist) if tracks == nil { return nil } - return tracks + return tracks.(rest.Repository) } // savePlaylist creates a new playlist, assigning the owner from context. @@ -64,7 +70,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri pls.UploadedImage = "" // Managed by image upload endpoint pls.ExternalImageURL = "" // Managed by M3U import / plugins only pls.EvaluatedAt = nil // Server-managed - err := s.ds.Playlist().Put(ctx, pls) + err := s.ds.Playlist(ctx).Put(pls) if err != nil { return "", err } @@ -73,107 +79,53 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri // updatePlaylistEntity updates playlist metadata with permission checks. // Used by the REST API wrapper. -// -// 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 { +func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error { current, err := s.checkWritable(ctx, id) if err != nil { - return err + switch { + case errors.Is(err, model.ErrNotFound): + return rest.ErrNotFound + case errors.Is(err, model.ErrNotAuthorized): + return rest.ErrPermissionDenied + default: + return err + } } - - sent := sentFields(cols) - usr, _ := request.UserFrom(ctx) - ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID - if !usr.IsAdmin && ownerChanged { + if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { return rest.ErrPermissionDenied } - nameChanged := sent("name") && entity.Name != current.Name - commentChanged := sent("comment") && entity.Comment != current.Comment - rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules) + contentChanged := entity.Name != current.Name || + entity.Comment != current.Comment || + (entity.OwnerID != "" && entity.OwnerID != current.OwnerID) || + !rulesEqual(current.Rules, entity.Rules) - 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 { + if contentChanged { + if entity.OwnerID != "" { + current.OwnerID = entity.OwnerID + } current.Rules = entity.Rules - current.EvaluatedAt = nil // force re-evaluation on next read - current.ImportedHash = "" // rules no longer match the source file; next scan must re-import it + if current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + } + return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) } - if sent("sync") && current.Path != "" && current.Sync != entity.Sync { - current.Sync = entity.Sync - } - 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 { + // Only sync/public changed — skip updatedAt so cover art URLs stay stable + var cols []string + if current.Path != "" && current.Sync != entity.Sync { current.Sync = entity.Sync - updateCols = append(updateCols, "sync") + cols = append(cols, "sync") } - if sent("public") && current.Public != entity.Public { + if current.Public != entity.Public { current.Public = entity.Public - updateCols = append(updateCols, "public") + cols = append(cols, "public") } - if len(updateCols) == 0 { + if len(cols) == 0 { return nil } - return s.ds.Playlist().Put(ctx, 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 - } + return s.ds.Playlist(ctx).Put(current, cols...) } func rulesEqual(a, b *criteria.Criteria) bool { diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index e82dd9df5..90d22327a 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -5,7 +5,7 @@ import ( "time" "github.com/deluan/rest" - "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -31,21 +31,21 @@ var _ = Describe("REST Adapter", func() { }) Describe("NewRepository", func() { - var repo rest.Persistable[model.Playlist] + var repo rest.Persistable BeforeEach(func() { mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) Describe("Save", func() { It("sets the owner from the context user", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) pls := &model.Playlist{Name: "New Playlist"} - id, err := repo.Save(ctx, pls) + id, err := repo.Save(pls) Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeEmpty()) Expect(pls.OwnerID).To(Equal("user-1")) @@ -53,16 +53,17 @@ var _ = Describe("REST Adapter", func() { It("forces a new creation by clearing ID", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) pls := &model.Playlist{ID: "should-be-cleared", Name: "New"} - _, err := repo.Save(ctx, pls) + _, err := repo.Save(pls) Expect(err).ToNot(HaveOccurred()) Expect(pls.ID).ToNot(Equal("should-be-cleared")) }) 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.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) + now := time.Now() pls := &model.Playlist{ Name: "Legit Playlist", Comment: "A comment", @@ -72,9 +73,9 @@ var _ = Describe("REST Adapter", func() { Sync: true, UploadedImage: "injected-image-path", ExternalImageURL: "http://evil.example.com/ssrf", - EvaluatedAt: new(time.Now()), + EvaluatedAt: &now, } - _, err := repo.Save(ctx, pls) + _, err := repo.Save(pls) Expect(err).ToNot(HaveOccurred()) saved := mockPlsRepo.Last @@ -95,55 +96,36 @@ var _ = Describe("REST Adapter", func() { Describe("Update", func() { It("allows owner to update their playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) pls := &model.Playlist{Name: "Updated"} - err := repo.Update(ctx, "pls-1", *pls) + err := repo.Update("pls-1", pls) Expect(err).ToNot(HaveOccurred()) }) It("allows admin to update any playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) pls := &model.Playlist{Name: "Updated"} - err := repo.Update(ctx, "pls-1", *pls) + err := repo.Update("pls-1", pls) Expect(err).ToNot(HaveOccurred()) }) It("denies non-owner, non-admin", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) pls := &model.Playlist{Name: "Updated"} - err := repo.Update(ctx, "pls-1", *pls) + err := repo.Update("pls-1", pls) Expect(err).To(Equal(rest.ErrPermissionDenied)) }) It("denies regular user from changing ownership", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"} - err := repo.Update(ctx, "pls-1", *pls) + err := repo.Update("pls-1", pls) 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.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{OwnerID: "other-user"} - err := repo.Update(ctx, "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", @@ -152,33 +134,14 @@ var _ = Describe("REST Adapter", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}}, } ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}} pls := &model.Playlist{Name: "Smart Playlist", Rules: newRules} - err := repo.Update(ctx, "smart-1", *pls) + err := repo.Update("smart-1", pls) Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) }) - It("invalidates the imported hash when rules change, so the next scan re-syncs the file", func() { - mockPlsRepo.Data["smart-1"] = &model.Playlist{ - ID: "smart-1", - Name: "Smart Playlist", - OwnerID: "user-1", - Path: "/music/smart.nsp", - Sync: true, - ImportedHash: hashOf("file content"), - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}}, - } - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}} - pls := &model.Playlist{Rules: newRules} - err := repo.Update(ctx, "smart-1", *pls, "rules") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.ImportedHash).To(BeEmpty()) - }) - It("allows toggling sync for file-backed playlists", func() { originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) mockPlsRepo.Data["file-pls"] = &model.Playlist{ @@ -190,9 +153,9 @@ var _ = Describe("REST Adapter", func() { UpdatedAt: originalTime, } ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) pls := &model.Playlist{Name: "File Playlist", Sync: false} - err := repo.Update(ctx, "file-pls", *pls) + err := repo.Update("file-pls", pls) Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Sync).To(BeFalse()) Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) @@ -207,9 +170,9 @@ var _ = Describe("REST Adapter", func() { Sync: false, } ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) pls := &model.Playlist{Name: "Manual Playlist", Sync: true} - err := repo.Update(ctx, "manual-pls", *pls) + err := repo.Update("manual-pls", pls) Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last).To(BeNil()) }) @@ -224,9 +187,9 @@ var _ = Describe("REST Adapter", func() { UpdatedAt: originalTime, } ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) pls := &model.Playlist{Name: "My Playlist", Public: true} - err := repo.Update(ctx, "pls-pub", *pls) + err := repo.Update("pls-pub", pls) Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Public).To(BeTrue()) Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) @@ -241,9 +204,9 @@ var _ = Describe("REST Adapter", func() { Sync: true, } ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) pls := &model.Playlist{Name: "New Name", Sync: false} - err := repo.Update(ctx, "file-pls2", *pls) + err := repo.Update("file-pls2", pls) Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last.Name).To(Equal("New Name")) Expect(mockPlsRepo.Last.Sync).To(BeFalse()) @@ -251,208 +214,26 @@ var _ = Describe("REST Adapter", func() { It("returns rest.ErrNotFound when playlist doesn't exist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) + repo = ps.NewRepository(ctx).(rest.Persistable) pls := &model.Playlist{Name: "Updated"} - err := repo.Update(ctx, "nonexistent", *pls) + 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.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"} - err := repo.Update(ctx, "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("resets EvaluatedAt when rules change", func() { - evaluatedAt := time.Now().Add(-1 * time.Hour) - mockPlsRepo.Data["smart-reset"] = &model.Playlist{ - ID: "smart-reset", - Name: "Smart", - OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, - EvaluatedAt: &evaluatedAt, - } - repo = ps.Repository().(rest.Persistable[model.Playlist]) - newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}} - err := repo.Update(ctx, "smart-reset", model.Playlist{Rules: newRules}, "rules") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.EvaluatedAt).To(BeNil()) - }) - - It("keeps EvaluatedAt when rules are not changed", func() { - evaluatedAt := time.Now().Add(-1 * time.Hour) - mockPlsRepo.Data["smart-keep"] = &model.Playlist{ - ID: "smart-keep", - Name: "Smart", - OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, - EvaluatedAt: &evaluatedAt, - } - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "smart-keep", model.Playlist{Name: "Renamed Smart"}, "name") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.EvaluatedAt).ToNot(BeNil()) - Expect(*mockPlsRepo.Last.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) - }) - - It("updates name and rules together (smart-playlist Edit form)", func() { - mockPlsRepo.Data["smart-edit"] = &model.Playlist{ - ID: "smart-edit", - Name: "Smart Original", - Comment: "smart comment", - OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, - } - repo = ps.Repository().(rest.Persistable[model.Playlist]) - newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"} - err := repo.Update(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - // 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(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "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() { It("delegates to service Delete with permission checks", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Delete(ctx, "pls-1") + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Delete("pls-1") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1")) }) It("denies non-owner", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Delete(ctx, "pls-1") + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Delete("pls-1") Expect(err).To(Equal(rest.ErrPermissionDenied)) }) }) diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go index 345606324..b0865e78b 100644 --- a/core/publicurl/publicurl.go +++ b/core/publicurl/publicurl.go @@ -2,7 +2,7 @@ package publicurl import ( "cmp" - "context" + "net/http" "net/url" "path" "strconv" @@ -13,36 +13,35 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/gg" ) // ImageURL generates a public URL for artwork images. // It creates a signed token for the artwork ID and builds a complete public URL. -func ImageURL(ctx context.Context, artID model.ArtworkID, size int) string { +func ImageURL(req *http.Request, artID model.ArtworkID, size int) string { token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()}) uri := path.Join(consts.URLPathPublicImages, token) params := url.Values{} if size > 0 { params.Add("size", strconv.Itoa(size)) } - return PublicURL(ctx, uri, params) + return PublicURL(req, uri, params) } // PublicURL builds a full URL for public-facing resources. -// It uses ShareURL from config if available, otherwise falls back to the address the -// client used to reach the server, recorded in the context. -func PublicURL(ctx context.Context, u string, params url.Values) string { +// It uses ShareURL from config if available, otherwise falls back to extracting +// the scheme and host from the provided http.Request. +// If req is nil and ShareURL is not set, it defaults to http://localhost. +func PublicURL(req *http.Request, u string, params url.Values) string { if conf.Server.ShareURL == "" { - return AbsoluteURL(ctx, u, params) + return AbsoluteURL(req, u, params) } shareUrl, err := url.Parse(conf.Server.ShareURL) if err != nil { - return AbsoluteURL(ctx, u, params) + return AbsoluteURL(req, u, params) } buildUrl, err := url.Parse(u) if err != nil { - return AbsoluteURL(ctx, u, params) + return AbsoluteURL(req, u, params) } buildUrl.Scheme = shareUrl.Scheme buildUrl.Host = shareUrl.Host @@ -56,12 +55,13 @@ func PublicURL(ctx context.Context, u string, params url.Values) string { } // AbsoluteURL builds an absolute URL from a relative path. -// It uses BaseHost/BaseScheme from config if available, otherwise the address the client -// used to reach the server, recorded in the context by the server's address middleware. -func AbsoluteURL(ctx context.Context, u string, params url.Values) string { +// It uses BaseHost/BaseScheme from config if available, otherwise extracts +// the scheme and host from the http.Request. +// If req is nil and BaseHost is not set, it defaults to http://localhost. +func AbsoluteURL(req *http.Request, u string, params url.Values) string { buildUrl, err := url.Parse(u) if err != nil { - log.Error(ctx, "Failed to parse URL path", "url", u, err) + log.Error(req.Context(), "Failed to parse URL path", "url", u, err) return "" } if strings.HasPrefix(u, "/") { @@ -69,13 +69,12 @@ func AbsoluteURL(ctx context.Context, u string, params url.Values) string { if conf.Server.BaseHost != "" { buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http") buildUrl.Host = conf.Server.BaseHost - } else if scheme, host, ok := request.ServerAddressFrom(ctx); ok { - buildUrl.Scheme = scheme - buildUrl.Host = host + } else if req != nil { + buildUrl.Scheme = req.URL.Scheme + buildUrl.Host = req.Host } else { - log.Debug(ctx, "Building a public URL with no public address available; set ShareURL to make it reachable", "url", u) - buildUrl.Scheme = gg.If(conf.Server.TLSEnabled(), "https", "http") - buildUrl.Host = "localhost:" + strconv.Itoa(conf.Server.Port) + buildUrl.Scheme = "http" + buildUrl.Host = "localhost" } } if len(params) > 0 { diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go index 0f8ea806c..a195fb9cd 100644 --- a/core/publicurl/publicurl_test.go +++ b/core/publicurl/publicurl_test.go @@ -1,7 +1,7 @@ package publicurl_test import ( - "context" + "net/http" "net/url" "testing" @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/core/publicurl" "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" @@ -37,17 +36,24 @@ var _ = Describe("Public URL Utilities", func() { }) It("uses ShareURL as the base", func() { - result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.PublicURL(r, "/path/to/resource", nil) Expect(result).To(Equal("https://share.example.com/path/to/resource")) }) It("includes query parameters", func() { + r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"size": []string{"300"}, "format": []string{"png"}} - result := publicurl.PublicURL(context.Background(), "/image/123", params) + result := publicurl.PublicURL(r, "/image/123", params) Expect(result).To(ContainSubstring("https://share.example.com/image/123")) Expect(result).To(ContainSubstring("size=300")) Expect(result).To(ContainSubstring("format=png")) }) + + It("works without a request", func() { + result := publicurl.PublicURL(nil, "/path/to/resource", nil) + Expect(result).To(Equal("https://share.example.com/path/to/resource")) + }) }) When("ShareURL includes a path", func() { @@ -56,19 +62,21 @@ var _ = Describe("Public URL Utilities", func() { }) It("prepends the ShareURL path to the resource", func() { - result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil) + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.PublicURL(r, "/share/img/hash", nil) Expect(result).To(Equal("https://example.com/navi/share/img/hash")) }) It("prepends the ShareURL path and includes query parameters", func() { + r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"size": []string{"600"}} - result := publicurl.PublicURL(context.Background(), "/share/img/hash", params) + result := publicurl.PublicURL(r, "/share/img/hash", params) Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600")) }) It("handles trailing slash in ShareURL path", func() { conf.Server.ShareURL = "https://example.com/navi/" - result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil) + result := publicurl.PublicURL(nil, "/share/img/hash", nil) Expect(result).To(Equal("https://example.com/navi/share/img/hash")) }) }) @@ -79,15 +87,15 @@ var _ = Describe("Public URL Utilities", func() { }) It("falls back to AbsoluteURL with request", func() { - ctx := request.WithServerAddress(context.Background(), "https", "myserver.com") - result := publicurl.PublicURL(ctx, "/path/to/resource", nil) + r, _ := http.NewRequest("GET", "https://myserver.com/test", nil) + r.Host = "myserver.com" + result := publicurl.PublicURL(r, "/path/to/resource", nil) Expect(result).To(Equal("https://myserver.com/path/to/resource")) }) - It("falls back to localhost on the configured port without request", func() { - conf.Server.Port = 4533 - result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost:4533/path/to/resource")) + It("falls back to localhost without request", func() { + result := publicurl.PublicURL(nil, "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost/path/to/resource")) }) }) }) @@ -101,13 +109,15 @@ var _ = Describe("Public URL Utilities", func() { }) It("uses BaseHost and BaseScheme", func() { - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) Expect(result).To(Equal("https://configured.example.com/path/to/resource")) }) It("defaults to http scheme if BaseScheme is empty", func() { conf.Server.BaseScheme = "" - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) Expect(result).To(Equal("http://configured.example.com/path/to/resource")) }) }) @@ -119,30 +129,15 @@ var _ = Describe("Public URL Utilities", func() { }) It("extracts host from request", func() { - ctx := request.WithServerAddress(context.Background(), "https", "request.example.com") - result := publicurl.AbsoluteURL(ctx, "/path/to/resource", nil) + r, _ := http.NewRequest("GET", "https://request.example.com/test", nil) + r.Host = "request.example.com" + result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) Expect(result).To(Equal("https://request.example.com/path/to/resource")) }) - It("falls back to localhost on the configured port without request", func() { - conf.Server.Port = 8080 - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost:8080/path/to/resource")) - }) - - It("uses https in the fallback when TLS is configured", func() { - conf.Server.Port = 4533 - conf.Server.TLSCert = "cert.pem" - conf.Server.TLSKey = "key.pem" - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("https://localhost:4533/path/to/resource")) - }) - - It("stays on http when only the certificate is configured", func() { - conf.Server.Port = 4533 - conf.Server.TLSCert = "cert.pem" - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost:4533/path/to/resource")) + It("falls back to localhost without request", func() { + result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost/path/to/resource")) }) }) @@ -154,21 +149,24 @@ var _ = Describe("Public URL Utilities", func() { }) It("prepends BasePath to the URL", func() { - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) Expect(result).To(Equal("https://example.com/navidrome/path/to/resource")) }) }) It("passes through absolute URLs unchanged", func() { - result := publicurl.AbsoluteURL(context.Background(), "https://other.example.com/path", nil) + r, _ := http.NewRequest("GET", "http://localhost/test", nil) + result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil) Expect(result).To(Equal("https://other.example.com/path")) }) It("includes query parameters", func() { conf.Server.BaseHost = "example.com" conf.Server.BaseScheme = "https" + r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"key": []string{"value"}} - result := publicurl.AbsoluteURL(context.Background(), "/path", params) + result := publicurl.AbsoluteURL(r, "/path", params) Expect(result).To(Equal("https://example.com/path?key=value")) }) }) @@ -177,56 +175,25 @@ var _ = Describe("Public URL Utilities", func() { BeforeEach(func() { conf.Server.ShareURL = "https://share.example.com" // Initialize JWT auth for token generation - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) + auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) }) It("generates a URL with the artwork token", func() { artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil) - result := publicurl.ImageURL(context.Background(), artID, 0) + result := publicurl.ImageURL(nil, artID, 0) Expect(result).To(HavePrefix("https://share.example.com/share/img/")) }) It("includes size parameter when provided", func() { artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil) - result := publicurl.ImageURL(context.Background(), artID, 300) + result := publicurl.ImageURL(nil, artID, 300) Expect(result).To(ContainSubstring("size=300")) }) It("omits size parameter when zero", func() { artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) - result := publicurl.ImageURL(context.Background(), artID, 0) + result := publicurl.ImageURL(nil, artID, 0) Expect(result).ToNot(ContainSubstring("size=")) }) }) - - Describe("ImageURL address precedence", func() { - var artID model.ArtworkID - - BeforeEach(func() { - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) - artID = model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) - }) - - It("uses the address of the request that triggered the call", func() { - ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") - - result := publicurl.ImageURL(ctx, artID, 300) - Expect(result).To(HavePrefix("https://music.example.com/share/img/")) - Expect(result).To(ContainSubstring("size=300")) - }) - - It("prefers ShareURL over the address in the context", func() { - conf.Server.ShareURL = "https://share.example.com" - ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") - - result := publicurl.ImageURL(ctx, artID, 0) - Expect(result).To(HavePrefix("https://share.example.com/share/img/")) - }) - - It("falls back to localhost on the configured port when no address is available", func() { - conf.Server.Port = 4533 - result := publicurl.ImageURL(context.Background(), artID, 0) - Expect(result).To(HavePrefix("http://localhost:4533/share/img/")) - }) - }) }) diff --git a/core/quickconnect/quickconnect.go b/core/quickconnect/quickconnect.go deleted file mode 100644 index 9b69bc904..000000000 --- a/core/quickconnect/quickconnect.go +++ /dev/null @@ -1,203 +0,0 @@ -// Package quickconnect implements Jellyfin-style Quick Connect: a new client shows a short code, and -// an already signed-in user approves it, so the client can sign in without a password. -package quickconnect - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "strings" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/random" - "github.com/navidrome/navidrome/utils/singleton" -) - -const timeout = 10 * time.Minute - -// Initiate is unauthenticated, so the number of live requests must be bounded. -const maxPending = 1000 - -var ( - ErrAlreadyAuthorized = errors.New("quick connect request already authorized") - ErrTooManyRequests = errors.New("too many pending quick connect requests") -) - -type Device struct { - ID string - Name string - App string - AppVersion string -} - -type Request struct { - Device Device - Secret string - Code string - DateAdded time.Time - UserID string -} - -func (r Request) Authorized() bool { - return r.UserID != "" -} - -type QuickConnect interface { - Initiate(device Device) (Request, error) - Status(secret string) (Request, error) - // Lookup returns a request still waiting for approval. - Lookup(code string) (Request, error) - Authorize(code, userID string) (Request, error) - // Redeem returns the id of the user who approved the request. It succeeds only once per secret. - Redeem(secret string) (string, error) -} - -type entry struct { - Request - expiresAt time.Time -} - -type quickConnect struct { - mu sync.Mutex - bySecret map[string]*entry - byCode map[string]*entry -} - -// GetInstance returns the shared store: the Jellyfin and native API routers must see the same requests. -func GetInstance() QuickConnect { - return singleton.GetInstance(newStore) -} - -func New() QuickConnect { - return newStore() -} - -func newStore() *quickConnect { - return &quickConnect{ - bySecret: map[string]*entry{}, - byCode: map[string]*entry{}, - } -} - -// Enabled reports whether users can approve codes from the web UI, which needs the Jellyfin API. -func Enabled() bool { - return conf.Server.Jellyfin.Enabled && conf.Server.Jellyfin.QuickConnect -} - -func (qc *quickConnect) Initiate(device Device) (Request, error) { - qc.mu.Lock() - defer qc.mu.Unlock() - qc.expire() - if len(qc.bySecret) >= maxPending { - return Request{}, ErrTooManyRequests - } - // The fields may be substrings of a much larger header; copy them so the header isn't retained. - device = Device{ID: strings.Clone(device.ID), Name: strings.Clone(device.Name), - App: strings.Clone(device.App), AppVersion: strings.Clone(device.AppVersion)} - now := time.Now() - e := &entry{ - Request: Request{Device: device, Secret: newSecret(), Code: qc.newCode(), DateAdded: now}, - expiresAt: now.Add(timeout), - } - qc.bySecret[e.Secret] = e - qc.byCode[e.Code] = e - return e.Request, nil -} - -func (qc *quickConnect) Status(secret string) (Request, error) { - qc.mu.Lock() - defer qc.mu.Unlock() - e, err := qc.find(qc.bySecret, secret) - if err != nil { - return Request{}, err - } - return e.Request, nil -} - -func (qc *quickConnect) Lookup(code string) (Request, error) { - qc.mu.Lock() - defer qc.mu.Unlock() - e, err := qc.findPending(code) - if err != nil { - return Request{}, err - } - return e.Request, nil -} - -func (qc *quickConnect) Authorize(code, userID string) (Request, error) { - qc.mu.Lock() - defer qc.mu.Unlock() - e, err := qc.findPending(code) - if err != nil { - return Request{}, err - } - e.UserID = userID - e.expiresAt = time.Now().Add(timeout) - return e.Request, nil -} - -func (qc *quickConnect) Redeem(secret string) (string, error) { - qc.mu.Lock() - defer qc.mu.Unlock() - e, err := qc.find(qc.bySecret, secret) - if err != nil || !e.Authorized() { - return "", model.ErrNotFound - } - qc.remove(e) - return e.UserID, nil -} - -func (qc *quickConnect) find(index map[string]*entry, key string) (*entry, error) { - qc.expire() - e, ok := index[key] - if !ok { - return nil, model.ErrNotFound - } - return e, nil -} - -func (qc *quickConnect) findPending(code string) (*entry, error) { - e, err := qc.find(qc.byCode, normalizeCode(code)) - if err == nil && e.Authorized() { - return nil, ErrAlreadyAuthorized - } - return e, err -} - -func (qc *quickConnect) expire() { - now := time.Now() - for _, e := range qc.bySecret { - if !now.Before(e.expiresAt) { - qc.remove(e) - } - } -} - -func (qc *quickConnect) remove(e *entry) { - delete(qc.bySecret, e.Secret) - delete(qc.byCode, e.Code) -} - -func (qc *quickConnect) newCode() string { - for { - code := fmt.Sprintf("%06d", random.Int64N(900000)+100000) - if _, taken := qc.byCode[code]; !taken { - return code - } - } -} - -func newSecret() string { - b := make([]byte, 32) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - -// Users often type the code in groups ("123 456"). -func normalizeCode(code string) string { - return strings.Join(strings.Fields(code), "") -} diff --git a/core/quickconnect/quickconnect_suite_test.go b/core/quickconnect/quickconnect_suite_test.go deleted file mode 100644 index 029f4e333..000000000 --- a/core/quickconnect/quickconnect_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package quickconnect - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestQuickConnect(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "QuickConnect Suite") -} diff --git a/core/quickconnect/quickconnect_test.go b/core/quickconnect/quickconnect_test.go deleted file mode 100644 index b03186807..000000000 --- a/core/quickconnect/quickconnect_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package quickconnect - -import ( - "testing" - "testing/synctest" - "time" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var device = Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"} - -var _ = Describe("QuickConnect", func() { - var qc QuickConnect - - BeforeEach(func() { - qc = New() - }) - - Describe("Initiate", func() { - It("creates a pending request with a 6-digit code and a random secret", func() { - req, err := qc.Initiate(device) - Expect(err).ToNot(HaveOccurred()) - Expect(req.Code).To(MatchRegexp(`^[1-9]\d{5}$`)) - Expect(req.Secret).To(MatchRegexp(`^[0-9a-f]{64}$`)) - Expect(req.Device).To(Equal(device)) - Expect(req.DateAdded).ToNot(BeZero()) - Expect(req.Authorized()).To(BeFalse()) - }) - - It("never gives two live requests the same code or secret", func() { - codes := map[string]bool{} - secrets := map[string]bool{} - for range 500 { - req, err := qc.Initiate(device) - Expect(err).ToNot(HaveOccurred()) - codes[req.Code] = true - secrets[req.Secret] = true - } - Expect(codes).To(HaveLen(500)) - Expect(secrets).To(HaveLen(500)) - }) - - It("refuses new requests when too many are pending", func() { - for range maxPending { - _, err := qc.Initiate(device) - Expect(err).ToNot(HaveOccurred()) - } - _, err := qc.Initiate(device) - Expect(err).To(MatchError(ErrTooManyRequests)) - }) - }) - - Describe("Status", func() { - It("returns the request for a known secret", func() { - req, _ := qc.Initiate(device) - got, err := qc.Status(req.Secret) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(Equal(req)) - }) - - It("returns ErrNotFound for an unknown secret", func() { - _, err := qc.Status("nope") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("Lookup", func() { - It("finds a request by code, ignoring spaces", func() { - req, _ := qc.Initiate(device) - got, err := qc.Lookup(req.Code[:3] + " " + req.Code[3:]) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(Equal(req)) - }) - - It("returns ErrNotFound for an unknown code", func() { - _, err := qc.Lookup("000000") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("refuses a request that is already authorized", func() { - req, _ := qc.Initiate(device) - _, _ = qc.Authorize(req.Code, "user-1") - _, err := qc.Lookup(req.Code) - Expect(err).To(MatchError(ErrAlreadyAuthorized)) - }) - }) - - Describe("Authorize", func() { - It("marks the request as authorized by the user", func() { - req, _ := qc.Initiate(device) - got, err := qc.Authorize(" "+req.Code+" ", "user-1") - Expect(err).ToNot(HaveOccurred()) - Expect(got.Authorized()).To(BeTrue()) - Expect(got.UserID).To(Equal("user-1")) - - status, _ := qc.Status(req.Secret) - Expect(status.Authorized()).To(BeTrue()) - }) - - It("refuses a request that is already authorized", func() { - req, _ := qc.Initiate(device) - _, _ = qc.Authorize(req.Code, "user-1") - _, err := qc.Authorize(req.Code, "user-2") - Expect(err).To(MatchError(ErrAlreadyAuthorized)) - }) - - It("returns ErrNotFound for an unknown code", func() { - _, err := qc.Authorize("000000", "user-1") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("Redeem", func() { - It("returns the approving user once, then forgets the request", func() { - req, _ := qc.Initiate(device) - _, _ = qc.Authorize(req.Code, "user-1") - - userID, err := qc.Redeem(req.Secret) - Expect(err).ToNot(HaveOccurred()) - Expect(userID).To(Equal("user-1")) - - _, err = qc.Redeem(req.Secret) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = qc.Status(req.Secret) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = qc.Lookup(req.Code) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("refuses a request that is not authorized yet", func() { - req, _ := qc.Initiate(device) - _, err := qc.Redeem(req.Secret) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = qc.Status(req.Secret) - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) - -// Plain tests: testing/synctest needs a *testing.T, which Ginkgo doesn't give. -func TestPendingRequestExpires(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - qc := New() - req, _ := qc.Initiate(device) - - time.Sleep(timeout - time.Second) - _, err := qc.Status(req.Secret) - g.Expect(err).ToNot(HaveOccurred()) - - time.Sleep(2 * time.Second) - _, err = qc.Status(req.Secret) - g.Expect(err).To(MatchError(model.ErrNotFound)) - _, err = qc.Authorize(req.Code, "user-1") - g.Expect(err).To(MatchError(model.ErrNotFound)) - }) -} - -func TestAuthorizeExtendsExpiry(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - qc := New() - req, _ := qc.Initiate(device) - - time.Sleep(timeout - time.Second) - _, err := qc.Authorize(req.Code, "user-1") - g.Expect(err).ToNot(HaveOccurred()) - - time.Sleep(timeout - time.Second) - userID, err := qc.Redeem(req.Secret) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(userID).To(Equal("user-1")) - }) -} - -func TestExpiredRequestsFreeCapacity(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - qc := New() - for range maxPending { - _, _ = qc.Initiate(device) - } - time.Sleep(timeout + time.Second) - _, err := qc.Initiate(device) - g.Expect(err).ToNot(HaveOccurred()) - }) -} diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 30bced623..67593e9eb 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -5,36 +5,10 @@ import ( "errors" "time" - "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" ) -const ( - minRetryDelay = 5 * time.Second - maxRetryDelay = 4 * time.Minute - // maxRetryShift caps the exponent so the shift never overflows int64. - // minRetryDelay<<6 = 320s already exceeds maxRetryDelay, so 6 reaches the ceiling. - maxRetryShift = 6 -) - -// backoffDelay returns the delay for a zero-based retry index (0 = first retry): -// minRetryDelay doubled per prior failure, clamped to maxRetryDelay. -func backoffDelay(failures int) time.Duration { - if failures < 0 { - failures = 0 - } - if failures >= maxRetryShift { - return maxRetryDelay - } - d := minRetryDelay << failures - if d > maxRetryDelay { - return maxRetryDelay - } - return d -} - // Loader is a function that loads a scrobbler by name. // It returns the scrobbler and true if found, or nil and false if not available. // This allows the buffered scrobbler to always get the current plugin instance. @@ -70,7 +44,7 @@ type bufferedScrobbler struct { loader Loader service string wakeSignal chan struct{} - ctx context.Context //nolint:containedctx // scrobbler lifecycle ctx, cancelled by Stop + ctx context.Context cancel context.CancelFunc } @@ -97,7 +71,7 @@ func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track } func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { - err := b.ds.ScrobbleBuffer().Enqueue(ctx, b.service, userId, s.ID, s.TimeStamp) + err := b.ds.ScrobbleBuffer(ctx).Enqueue(b.service, userId, s.ID, s.TimeStamp) if err != nil { return err } @@ -123,98 +97,72 @@ func (b *bufferedScrobbler) sendWakeSignal() { } func (b *bufferedScrobbler) run(ctx context.Context) { - timer := time.NewTimer(time.Hour) - timer.Stop() - defer timer.Stop() - failures := 0 - backingOff := false for { - // While a backoff window is open the timer is already armed for the rest of it, so a - // wake (a new play enqueued) must not drain: that is the hammering this avoids. - if !backingOff { - if ok, retryIn := b.processQueue(ctx); ok { - failures = 0 - timer.Stop() - } else { - timer.Reset(max(backoffDelay(failures), retryIn)) - backingOff = true - if failures < maxRetryShift { - failures++ - } - } + if !b.processQueue(ctx) { + time.AfterFunc(5*time.Second, func() { + b.sendWakeSignal() + }) } select { case <-b.wakeSignal: - case <-timer.C: - backingOff = false + continue case <-ctx.Done(): return } } } -func (b *bufferedScrobbler) processQueue(ctx context.Context) (bool, time.Duration) { - buffer := b.ds.ScrobbleBuffer() - userIds, err := buffer.UserIDs(ctx, b.service) +func (b *bufferedScrobbler) processQueue(ctx context.Context) bool { + buffer := b.ds.ScrobbleBuffer(ctx) + userIds, err := buffer.UserIDs(b.service) if err != nil { log.Error(ctx, "Error retrieving userIds from scrobble buffer", "scrobbler", b.service, err) - return false, 0 + return false } result := true - var retryIn time.Duration for _, userId := range userIds { - ok, d := b.processUserQueue(ctx, userId) - if !ok { + if !b.processUserQueue(ctx, userId) { result = false - retryIn = max(retryIn, d) } } - return result, retryIn + return result } -func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) (bool, time.Duration) { - // 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().Get(ctx, 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() +func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool { + buffer := b.ds.ScrobbleBuffer(ctx) for { - entry, err := buffer.Next(ctx, b.service, userId) + entry, err := buffer.Next(b.service, userId) if err != nil { log.Error(ctx, "Error reading from scrobble buffer", "scrobbler", b.service, err) - return false, 0 + return false } if entry == nil { - return true, 0 + return true } s, ok := b.loader() if !ok { log.Warn(ctx, "Scrobbler not available, will retry later", "scrobbler", b.service) - return false, 0 + return false } log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist) err = s.Scrobble(ctx, entry.UserID, Scrobble{ MediaFile: entry.MediaFile, TimeStamp: entry.PlayTime, }) - if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok { + if errors.Is(err, ErrRetryLater) { log.Warn(ctx, "Could not send scrobble. Will be retried", "userId", entry.UserID, "track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err) - return false, retry.RetryIn + return false } if err != nil { log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service, "userId", entry.UserID, "artist", entry.Artist, "track", entry.Title, err) } - err = buffer.Dequeue(ctx, entry) + err = buffer.Dequeue(entry) if err != nil { log.Error(ctx, "Error removing entry from scrobble buffer", "userId", entry.UserID, "track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err) - return false, 0 + return false } } } diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index 6ecd92cab..9fbca6f71 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -2,13 +2,8 @@ package scrobbler import ( "context" - "errors" - "sync/atomic" - "testing" - "testing/synctest" "time" - "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -25,11 +20,8 @@ var _ = Describe("BufferedScrobbler", func() { BeforeEach(func() { ctx = context.Background() buffer = tests.CreateMockedScrobbleBufferRepo() - userRepo := tests.CreateMockUserRepo() - Expect(userRepo.Put(ctx, &model.User{ID: "user1", UserName: "alice"})).To(Succeed()) ds = &tests.MockDataStore{ MockedScrobbleBuffer: buffer, - MockedUser: userRepo, } scr = &fakeScrobbler{Authorized: true} bs = newBufferedScrobbler(ds, scr, "test") @@ -55,7 +47,7 @@ var _ = Describe("BufferedScrobbler", func() { track := model.MediaFile{ID: "123", Title: "Test Track"} now := time.Now() scrobble := Scrobble{MediaFile: track, TimeStamp: now} - Expect(buffer.Length(ctx)).To(Equal(int64(0))) + Expect(buffer.Length()).To(Equal(int64(0))) Expect(scr.ScrobbleCalled.Load()).To(BeFalse()) Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed()) @@ -70,16 +62,6 @@ 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{}) @@ -105,204 +87,3 @@ var _ = Describe("BufferedScrobbler", func() { } }) }) - -var _ = Describe("backoffDelay", func() { - DescribeTable("computes the exponential backoff curve clamped to the ceiling", - func(failures int, expected time.Duration) { - Expect(backoffDelay(failures)).To(Equal(expected)) - }, - Entry("first failure", 0, 5*time.Second), - Entry("second failure", 1, 10*time.Second), - Entry("third failure", 2, 20*time.Second), - Entry("fourth failure", 3, 40*time.Second), - Entry("fifth failure", 4, 80*time.Second), - Entry("sixth failure", 5, 160*time.Second), - Entry("reaches the ceiling", 6, 4*time.Minute), - Entry("stays clamped past the ceiling", 7, 4*time.Minute), - Entry("stays clamped for large values", 1000, 4*time.Minute), - Entry("negative is treated as zero", -1, 5*time.Second), - ) -}) - -// Drives the real run loop and asserts the exact retry schedule + recovery. Plain -// test: testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give. -func TestBufferedScrobblerBackoffSchedule(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - buffer := tests.CreateMockedScrobbleBufferRepo() - userRepo := tests.CreateMockUserRepo() - g.Expect(userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"})).To(Succeed()) - ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} - - flaky := &recoveringScrobbler{} - flaky.fail(ErrRetryLater) - bs := newBufferedScrobbler(ds, flaky, "flaky") - defer func() { bs.Stop(); synctest.Wait() }() - - // Let the loop settle on the empty buffer, then enqueue a scrobble. - synctest.Wait() - track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"} - g.Expect(bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: track, TimeStamp: time.Now()})).To(Succeed()) - - // First attempt fires immediately on the enqueue wake and is left buffered. - synctest.Wait() - g.Expect(flaky.count.Load()).To(Equal(int32(1))) - g.Expect(buffer.Length(t.Context())).To(Equal(int64(1))) - - // Each subsequent retry waits exactly double the previous: 5s, 10s, 20s, 40s. - for i, gap := range []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second} { - want := int32(i + 2) - time.Sleep(gap - time.Nanosecond) - synctest.Wait() - g.Expect(flaky.count.Load()).To(Equal(want-1), "retry fired before the %s backoff", gap) - time.Sleep(time.Nanosecond) - synctest.Wait() - g.Expect(flaky.count.Load()).To(Equal(want), "retry did not fire after the %s backoff", gap) - } - - // Once the service recovers, the buffered entry drains when the open - // backoff window closes (a wake alone must not drain it early). - flaky.succeed() - bs.sendWakeSignal() - synctest.Wait() - g.Expect(buffer.Length(t.Context())).To(Equal(int64(1)), "wake during backoff drained early") - time.Sleep(80 * time.Second) - synctest.Wait() - g.Expect(buffer.Length(t.Context())).To(Equal(int64(0))) - }) -} - -func TestBufferedScrobblerBackoffWindow(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - buffer := tests.CreateMockedScrobbleBufferRepo() - userRepo := tests.CreateMockUserRepo() - _ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"}) - ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} - scr := &fakeScrobbler{Authorized: true} - scr.SetError(errors.Join(errors.New("boom"), ErrRetryLater)) - bs := newBufferedScrobbler(ds, scr, "test") - defer bs.Stop() - - // First enqueue: one immediate attempt, then a 5s window opens. - _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 1 { - t.Fatalf("expected 1 attempt after first enqueue, got %d", got) - } - - // A wake inside the window must NOT trigger an early attempt. - time.Sleep(1 * time.Second) - _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "2"}, TimeStamp: time.Now()}) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 1 { - t.Fatalf("wake during backoff drained early: %d attempts", got) - } - - // When the 5s window closes, the retry happens. - time.Sleep(4100 * time.Millisecond) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 2 { - t.Fatalf("expected retry after window, got %d attempts", got) - } - }) -} - -func TestBufferedScrobblerHonorsServerDelay(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - buffer := tests.CreateMockedScrobbleBufferRepo() - userRepo := tests.CreateMockUserRepo() - _ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"}) - ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} - scr := &fakeScrobbler{Authorized: true} - scr.SetError(errors.Join(errors.New("429"), &agents.RetryLaterError{RetryIn: 30 * time.Second})) - bs := newBufferedScrobbler(ds, scr, "test") - defer bs.Stop() - - _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 1 { - t.Fatalf("expected 1 attempt, got %d", got) - } - - // The 5s exponential floor is overridden by the 30s server delay. - time.Sleep(20 * time.Second) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 1 { - t.Fatalf("retried before server delay elapsed: %d attempts", got) - } - time.Sleep(10100 * time.Millisecond) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 2 { - t.Fatalf("expected retry after server delay, got %d attempts", got) - } - }) -} - -// The drain visits users in an arbitrary order, so the longest delay must win regardless -// of which user was seen last. -func TestBufferedScrobblerTakesTheLongestServerDelayAcrossUsers(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - buffer := tests.CreateMockedScrobbleBufferRepo() - userRepo := tests.CreateMockUserRepo() - _ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"}) - _ = userRepo.Put(t.Context(), &model.User{ID: "user2", UserName: "bob"}) - ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} - scr := &recoveringScrobbler{delays: map[string]time.Duration{ - "user1": 10 * time.Second, - "user2": 45 * time.Second, - }} - // Both are buffered before the drain goroutine exists: it drains once on startup, and - // seeing only one user there would park it on that user's delay, ignoring the other. - _ = buffer.Enqueue(t.Context(), "test", "user1", "1", time.Now()) - _ = buffer.Enqueue(t.Context(), "test", "user2", "2", time.Now()) - bs := newBufferedScrobbler(ds, scr, "test") - defer bs.Stop() - - synctest.Wait() - if got := scr.count.Load(); got != 2 { - t.Fatalf("expected both users drained, got %d attempts", got) - } - - time.Sleep(30 * time.Second) - synctest.Wait() - if got := scr.count.Load(); got != 2 { - t.Fatalf("retried on the shorter delay: %d attempts", got) - } - time.Sleep(15100 * time.Millisecond) - synctest.Wait() - if got := scr.count.Load(); got != 4 { - t.Fatalf("expected a retry after the longest delay, got %d attempts", got) - } - }) -} - -// recoveringScrobbler is a race-safe Scrobbler whose error can be toggled while -// the buffered scrobbler's goroutine is draining, to exercise retry then recovery. -// With delays set, it instead fails every scrobble asking for that user's delay. -type recoveringScrobbler struct { - err atomic.Pointer[error] - count atomic.Int32 - delays map[string]time.Duration -} - -func (f *recoveringScrobbler) fail(err error) { f.err.Store(&err) } -func (f *recoveringScrobbler) succeed() { f.err.Store(nil) } - -func (f *recoveringScrobbler) IsAuthorized(context.Context, string) bool { return true } - -func (f *recoveringScrobbler) NowPlaying(context.Context, string, *model.MediaFile, int) error { - return nil -} - -func (f *recoveringScrobbler) Scrobble(_ context.Context, userId string, _ Scrobble) error { - f.count.Add(1) - if f.delays != nil { - return errors.Join(errors.New("429"), &agents.RetryLaterError{RetryIn: f.delays[userId]}) - } - if e := f.err.Load(); e != nil { - return *e - } - return nil -} - -func (f *recoveringScrobbler) PlaybackReport(context.Context, PlaybackSession) error { return nil } diff --git a/core/scrobbler/interfaces.go b/core/scrobbler/interfaces.go index 218b7a919..8a18bb37e 100644 --- a/core/scrobbler/interfaces.go +++ b/core/scrobbler/interfaces.go @@ -5,7 +5,6 @@ import ( "errors" "time" - "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" ) @@ -16,8 +15,7 @@ type Scrobble struct { var ( ErrNotAuthorized = errors.New("not authorized") - // ErrRetryLater is an alias of agents.ErrRetryLater so adapters and plugins share one identity. - ErrRetryLater = agents.ErrRetryLater + ErrRetryLater = errors.New("retry later") ErrUnrecoverable = errors.New("unrecoverable") ) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 541a8e92b..bdb261ef2 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -2,7 +2,6 @@ package scrobbler import ( "context" - "encoding/json" "maps" "slices" "sync" @@ -12,12 +11,10 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/singleton" - "github.com/navidrome/navidrome/utils/slice" ) const ( @@ -46,10 +43,6 @@ type PlaybackSession struct { PositionMs int64 PlaybackRate float64 LastReport time.Time - - // Verdict from the last report, for the expiry callback: its context carries only - // a stub user, so it cannot evaluate the filter itself. - filtered bool } type Submission struct { @@ -68,16 +61,15 @@ type ReportPlaybackParams struct { } type nowPlayingEntry struct { - ctx context.Context //nolint:containedctx // queued work item carries the request ctx to the worker + ctx context.Context userId string track *model.MediaFile position int } type playbackReportEntry struct { - ctx context.Context //nolint:containedctx // queued work item carries the request ctx to the worker - info PlaybackSession - filtered bool + ctx context.Context + info PlaybackSession } type PlayTracker interface { @@ -97,7 +89,6 @@ type playTracker struct { ds model.DataStore broker events.Broker playMap cache.SimpleCache[string, PlaybackSession] - sessionsMu sync.Mutex // serializes playMap check-then-write across concurrent reports builtinScrobblers map[string]Scrobbler pluginScrobblers map[string]Scrobbler pluginLoader PluginLoader @@ -153,7 +144,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug log.Trace("Enqueueing PlaybackReport for expired session", "session", info) info.State = StateExpired info.LastReport = time.Now() - p.enqueuePlaybackReport(ctx, info, info.filtered) + p.enqueuePlaybackReport(ctx, info) } }) @@ -258,12 +249,6 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler { return combined } -// hasPlayingSession reports whether clientId's current session is already playing mediaId. -func (p *playTracker) hasPlayingSession(clientId, mediaId string) bool { - cur, err := p.playMap.Get(clientId) - return err == nil && cur.MediaFile.ID == mediaId && cur.State == StatePlaying -} - func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration { if rate <= 0 { rate = 1.0 @@ -281,26 +266,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP now := time.Now() - // One verdict per report, reused by every dispatch below, so a filter reading - // annotations cannot decide differently on either side of incPlay. - var filtered bool - switch params.State { case StateStarting: - // Clients may send starting/playing unordered; a late "starting" must not downgrade - // a playing session, or position estimation freezes until the next report. - if p.hasPlayingSession(clientId, params.MediaId) { - log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId) - return nil - } - mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if err != nil { return err } - filtered = p.isFilteredOut(ctx, mf) info := PlaybackSession{ MediaFile: *mf, - filtered: filtered, Start: now, UserId: user.ID, Username: user.UserName, @@ -311,24 +284,16 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP PlaybackRate: params.PlaybackRate, LastReport: now, } - p.sessionsMu.Lock() - // re-check: a concurrent "playing" report may have created the session during the load above - if p.hasPlayingSession(clientId, params.MediaId) { - p.sessionsMu.Unlock() - log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId) - return nil - } err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) - p.sessionsMu.Unlock() if err != nil { log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } - p.enqueuePlaybackReport(ctx, info, filtered) + p.enqueuePlaybackReport(ctx, info) case StatePlaying, StatePaused: info, getErr := p.playMap.Get(clientId) if getErr != nil || info.MediaFile.ID != params.MediaId { - mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if err != nil { return err } @@ -345,32 +310,25 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP info.PositionMs = params.PositionMs info.PlaybackRate = params.PlaybackRate info.LastReport = now - filtered = p.isFilteredOut(ctx, &info.MediaFile) - info.filtered = filtered ttl := 30 * time.Minute if params.State == StatePlaying { ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) } log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl) - p.sessionsMu.Lock() err := p.playMap.AddWithTTL(clientId, info, ttl) - p.sessionsMu.Unlock() if err != nil { log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } - p.enqueuePlaybackReport(ctx, info, filtered) + p.enqueuePlaybackReport(ctx, info) case StateStopped: var loadedMF *model.MediaFile - haveVerdict := false if !params.IgnoreScrobble && player.ScrobbleEnabled { - mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if err != nil { return err } loadedMF = mf - filtered = p.isFilteredOut(ctx, mf) - haveVerdict = true trackDurationMs := int64(mf.Duration * 1000) threshold := min(trackDurationMs*50/100, 240_000) if params.PositionMs >= threshold { @@ -378,20 +336,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err) } - p.dispatchScrobble(ctx, mf, now, filtered) + p.dispatchScrobble(ctx, mf, now) } } - p.sessionsMu.Lock() - info, getErr := p.playMap.Get(clientId) - // A late stop for a previous track must not end the current session nor reach - // playback reporters, or presence-style plugins would clear the active track. - if getErr == nil && info.MediaFile.ID != params.MediaId { - p.sessionsMu.Unlock() - log.Trace(ctx, "Ignoring out-of-order stopped report for different track", "clientId", clientId, "stoppedMediaId", params.MediaId, "currentMediaId", info.MediaFile.ID) - return nil - } - p.playMap.Remove(clientId) - p.sessionsMu.Unlock() stoppedInfo := PlaybackSession{ UserId: user.ID, Username: user.UserName, @@ -402,42 +349,31 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP PlaybackRate: params.PlaybackRate, LastReport: now, } - if getErr == nil { + if info, getErr := p.playMap.Get(clientId); getErr == nil { stoppedInfo.MediaFile = info.MediaFile stoppedInfo.Start = info.Start } else { mf := loadedMF if mf == nil { var mfErr error - mf, mfErr = p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) + mf, mfErr = p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if mfErr != nil { return mfErr } } stoppedInfo.MediaFile = *mf } - if !haveVerdict { - filtered = p.isFilteredOut(ctx, &stoppedInfo.MediaFile) - } - p.enqueuePlaybackReport(ctx, stoppedInfo, filtered) + p.enqueuePlaybackReport(ctx, stoppedInfo) + p.playMap.Remove(clientId) } if conf.Server.EnableNowPlaying { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } - // 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 && + if !params.IgnoreScrobble && player.ScrobbleEnabled && (params.State == StateStarting || params.State == StatePlaying) { - if filtered { - log.Debug(ctx, "Ignoring external NowPlaying update for filtered track", "mediaId", params.MediaId) - } else if info, err := p.playMap.Get(clientId); err == nil { + if info, err := p.playMap.Get(clientId); err == nil { p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) } } @@ -445,14 +381,8 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP return nil } -func (p *playTracker) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) { - // The cache is process-global, so it holds every user's playback, across all libraries. +func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) { res := p.playMap.Values() - if user, ok := request.UserFrom(ctx); ok { - res = slice.Filter(res, func(s PlaybackSession) bool { - return user.HasLibraryAccess(s.MediaFile.LibraryID) - }) - } slices.SortFunc(res, func(a, b PlaybackSession) int { return b.Start.Compare(a.Start) }) @@ -477,12 +407,11 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro success := 0 for _, s := range submissions { - mf, err := p.ds.MediaFile().GetWithParticipants(ctx, s.TrackID) + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(s.TrackID) if err != nil { log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err) continue } - filtered := p.isFilteredOut(ctx, mf) err = p.incPlay(ctx, mf, s.Timestamp) if err != nil { log.Error(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", username, err) @@ -491,7 +420,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID) log.Info(ctx, "Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username, "timestamp", s.Timestamp) if player.ScrobbleEnabled { - p.dispatchScrobble(ctx, mf, s.Timestamp, filtered) + p.dispatchScrobble(ctx, mf, s.Timestamp) } } } @@ -504,57 +433,32 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, timestamp time.Time) error { return p.ds.WithTx(func(tx model.DataStore) error { - err := tx.MediaFile().IncPlayCount(ctx, track.ID, timestamp) + err := tx.MediaFile(ctx).IncPlayCount(track.ID, timestamp) if err != nil { return err } - err = tx.Album().IncPlayCount(ctx, track.AlbumID, timestamp) + err = tx.Album(ctx).IncPlayCount(track.AlbumID, timestamp) if err != nil { return err } for _, artist := range track.Participants[model.RoleArtist] { - err = tx.Artist().IncPlayCount(ctx, artist.ID, timestamp) + err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp) if err != nil { return err } } if conf.Server.EnableScrobbleHistory { - return tx.Scrobble().RecordScrobble(ctx, track.ID, timestamp) + return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp) } return nil }) } -// Take this verdict before incPlay mutates what a filter reads, and independently of -// which scrobblers are active: it can be stored on a session and dispatched much later. -// Any parse or query failure fails open, because filtering must not break scrobbling. -func (p *playTracker) isFilteredOut(ctx context.Context, t *model.MediaFile) bool { - u, _ := request.UserFrom(ctx) - if u.ScrobbleFilter == "" { - return false - } - var c criteria.Criteria - if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil { - log.Warn(ctx, "Invalid scrobble filter, ignoring", "user", u.UserName, err) - return false - } - match, err := p.ds.MediaFile().MatchesCriteria(ctx, t.ID, c) - if err != nil { - log.Warn(ctx, "Error evaluating scrobble filter, ignoring", "user", u.UserName, "track", t.Title, err) - return false - } - return match -} - -func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time, filtered bool) { +func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) { if t.Artist == consts.UnknownArtist { log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist) return } - if filtered { - log.Debug(ctx, "Ignoring external Scrobble for filtered track", "track", t.Title, "artist", t.Artist) - return - } allScrobblers := p.getActiveScrobblers() u, _ := request.UserFrom(ctx) @@ -580,10 +484,3 @@ 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 045827811..684b887fe 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -3,7 +3,6 @@ package scrobbler import ( "context" "errors" - "fmt" "net/http" "sync" "sync/atomic" @@ -14,7 +13,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" @@ -47,37 +45,6 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { return s, ok } -// flipOnPlayRepo reports one filter verdict before the play is counted and another -// after, reproducing a filter that reads annotations incPlay mutates. -type flipOnPlayRepo struct { - model.MediaFileRepository - before, after bool - played atomic.Bool -} - -func (r *flipOnPlayRepo) IncPlayCount(ctx context.Context, id string, ts time.Time) error { - r.played.Store(true) - return r.MediaFileRepository.IncPlayCount(ctx, id, ts) -} - -func (r *flipOnPlayRepo) MatchesCriteria(context.Context, string, criteria.Criteria) (bool, error) { - if r.played.Load() { - return r.after, nil - } - return r.before, nil -} - -// slowMediaFileRepo widens the window between a report's session check and its -// write, making check-then-write races reproducible. -type slowMediaFileRepo struct { - model.MediaFileRepository -} - -func (s *slowMediaFileRepo) GetWithParticipants(ctx context.Context, id string) (*model.MediaFile, error) { - time.Sleep(5 * time.Millisecond) - return s.MediaFileRepository.GetWithParticipants(ctx, id) -} - var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore @@ -92,7 +59,7 @@ var _ = Describe("PlayTracker", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ctx = GinkgoT().Context() - ctx = request.WithUser(ctx, model.User{ID: "u-1", Libraries: model.Libraries{{ID: 1}}}) + ctx = request.WithUser(ctx, model.User{ID: "u-1"}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) ds = &tests.MockDataStore{} fake = &fakeScrobbler{Authorized: true} @@ -108,7 +75,6 @@ var _ = Describe("PlayTracker", func() { track = model.MediaFile{ ID: "123", - LibraryID: 1, Title: "Track Title", Album: "Track Album", AlbumID: "al-1", @@ -119,13 +85,13 @@ var _ = Describe("PlayTracker", func() { model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1"), _p("ar-2", "Artist 2")}, }, } - _ = ds.MediaFile().Put(ctx, &track) + _ = ds.MediaFile(ctx).Put(&track) artist1 = model.Artist{ID: "ar-1"} - _ = ds.Artist().Put(ctx, &artist1) + _ = ds.Artist(ctx).Put(&artist1) artist2 = model.Artist{ID: "ar-2"} - _ = ds.Artist().Put(ctx, &artist2) + _ = ds.Artist(ctx).Put(&artist2) album = model.Album{ID: "al-1"} - _ = ds.Album().(*tests.MockAlbumRepo).Put(ctx, &album) + _ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album) }) AfterEach(func() { @@ -138,18 +104,11 @@ 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 track2.ID = "456" - _ = ds.MediaFile().Put(ctx, &track2) + _ = ds.MediaFile(ctx).Put(&track2) ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true}) _ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{ @@ -175,46 +134,6 @@ var _ = Describe("PlayTracker", func() { Expect(playing[1].Username).To(Equal("user-1")) Expect(playing[1].MediaFile.ID).To(Equal("123")) }) - - It("hides sessions playing from libraries the caller cannot access", func() { - hidden := track - hidden.ID = "789" - hidden.LibraryID = 2 - _ = ds.MediaFile().Put(ctx, &hidden) - reporter := request.WithPlayer( - request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), - model.Player{ScrobbleEnabled: true}, - ) - _ = tracker.ReportPlayback(reporter, ReportPlaybackParams{ - MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", - }) - - playing, err := tracker.GetNowPlaying(ctx) - - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(BeEmpty(), "u-1 is granted library 1 only") - }) - - It("shows every session to an admin", func() { - hidden := track - hidden.ID = "789" - hidden.LibraryID = 2 - _ = ds.MediaFile().Put(ctx, &hidden) - reporter := request.WithPlayer( - request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), - model.Player{ScrobbleEnabled: true}, - ) - _ = tracker.ReportPlayback(reporter, ReportPlaybackParams{ - MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", - }) - - adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "adm", IsAdmin: true}) - playing, err := tracker.GetNowPlaying(adminCtx) - - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(HaveLen(1)) - Expect(playing[0].MediaFile.ID).To(Equal("789")) - }) }) Describe("Expiration events", func() { @@ -334,7 +253,7 @@ var _ = Describe("PlayTracker", func() { }) It("increments play counts even if it cannot scrobble", func() { - fake.SetError(errors.New("error")) + fake.Error = errors.New("error") err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) @@ -360,11 +279,11 @@ var _ = Describe("PlayTracker", func() { Expect(err).ToNot(HaveOccurred()) mockDS := ds.(*tests.MockDataStore) - mockScrobble := mockDS.Scrobble().(*tests.MockScrobbleRepo) + mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo) 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.Unix())) + Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts)) }) It("does not record scrobble when history is disabled", func() { @@ -376,191 +295,12 @@ var _ = Describe("PlayTracker", func() { Expect(err).ToNot(HaveOccurred()) mockDS := ds.(*tests.MockDataStore) - mockScrobble := mockDS.Scrobble().(*tests.MockScrobbleRepo) + mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo) Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0)) }) }) }) - Describe("Scrobble filter", func() { - var repo *tests.MockMediaFileRepo - - BeforeEach(func() { - ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", - ScrobbleFilter: `{"all":[{"contains":{"title":"Track"}}]}`}) - repo = ds.MediaFile().(*tests.MockMediaFileRepo) - }) - - It("does not send a matching track to the agent", func() { - repo.MatchesCriteriaValue = true - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) - }) - - It("still increments play counts for a filtered track", func() { - repo.MatchesCriteriaValue = true - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(track.PlayCount).To(Equal(int64(1))) - Expect(album.PlayCount).To(Equal(int64(1))) - }) - - It("sends a non-matching track to the agent", func() { - repo.MatchesCriteriaValue = false - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) - }) - - It("fails open when evaluation errors", func() { - repo.MatchesCriteriaErr = errors.New("boom") - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) - }) - - It("fails open when the stored filter is not valid JSON", func() { - ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", ScrobbleFilter: `{broken`}) - repo.MatchesCriteriaValue = true - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) - }) - - It("does not send now-playing for a filtered track", func() { - repo.MatchesCriteriaValue = true - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) - }) - - It("does not send playback reports for a filtered track", func() { - repo.MatchesCriteriaValue = true - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) - }) - - It("sends playback reports for a non-matching track", func() { - repo.MatchesCriteriaValue = false - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) - }) - - It("evaluates the filter even when no scrobbler is active yet", func() { - // The verdict is stored on the session and dispatched at expiry, by which - // time a plugin scrobbler may have been enabled. - tracker.builtinScrobblers = map[string]Scrobbler{} - repo.MatchesCriteriaValue = true - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StatePlaying, ClientId: "player-12", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - stored, getErr := tracker.playMap.Get("player-12") - Expect(getErr).ToNot(HaveOccurred()) - Expect(stored.filtered).To(BeTrue()) - }) - - Context("when incPlay itself flips the filter", func() { - // A filter on playCount or lastPlayed changes verdict the moment incPlay - // commits, so the verdict has to be taken before it, not at dispatch time. - var flip *flipOnPlayRepo - - install := func(before, after bool) { - flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(), before: before, after: after} - ds.(*tests.MockDataStore).MockedMediaFile = flip - } - - It("does not scrobble a track the filter matched before the play was counted", func() { - install(true, false) - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") - Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) - }) - - It("still sends the stopped report when the filter only starts matching after the play", func() { - install(false, true) - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") - Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) - }) - - It("does not report an expired session for a filtered track", func() { - // The expiry callback runs with a stub user, so it cannot evaluate the - // filter itself and must reuse the verdict stored on the session. - info := PlaybackSession{ - MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1", - PlayerId: "player-9", PlayerName: "test-player", State: StatePlaying, filtered: true, - } - _ = tracker.playMap.AddWithTTL("player-9", info, 10*time.Millisecond) - - Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) - }) - - It("still reports an expired session for a track that is not filtered", func() { - info := PlaybackSession{ - MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1", - PlayerId: "player-10", PlayerName: "test-player", State: StatePlaying, filtered: false, - } - _ = tracker.playMap.AddWithTTL("player-10", info, 10*time.Millisecond) - - Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) - }) - - It("stores the verdict on the session so expiry can reuse it", func() { - repo.MatchesCriteriaValue = true - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StatePlaying, ClientId: "player-11", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - stored, getErr := tracker.playMap.Get("player-11") - Expect(getErr).ToNot(HaveOccurred()) - Expect(stored.filtered).To(BeTrue()) - }) - - It("does not scrobble a Submit whose filter matched before the play was counted", func() { - install(true, false) - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") - Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) - }) - }) - }) - Describe("ReportPlayback", func() { const defaultClientId = "client-1" @@ -629,23 +369,18 @@ var _ = Describe("PlayTracker", func() { Expect(playing).To(BeEmpty()) }) - It("starting replaces existing entry when switching tracks on same player", func() { - track2 := track - track2.ID = "456" - _ = ds.MediaFile().Put(ctx, &track2) - + It("starting replaces existing entry for same player", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "456", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) - Expect(playing[0].MediaFile.ID).To(Equal("456")) Expect(playing[0].State).To(Equal("starting")) Expect(playing[0].PositionMs).To(Equal(int64(0))) }) @@ -659,7 +394,7 @@ var _ = Describe("PlayTracker", func() { track2 := track track2.ID = "456" - _ = ds.MediaFile().Put(ctx, &track2) + _ = ds.MediaFile(ctx).Put(&track2) err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1", @@ -760,7 +495,7 @@ var _ = Describe("PlayTracker", func() { model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")}, }, } - _ = ds.MediaFile().Put(ctx, &longTrack) + _ = ds.MediaFile(ctx).Put(&longTrack) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, @@ -786,7 +521,6 @@ 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, }) @@ -797,7 +531,6 @@ 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() { @@ -954,119 +687,6 @@ var _ = Describe("PlayTracker", func() { }) }) - Describe("resilience (out-of-order reports)", func() { - BeforeEach(func() { - track2 := track - track2.ID = "456" - _ = ds.MediaFile().Put(ctx, &track2) - }) - - It("does not downgrade an actively playing session when a late starting report arrives for the same track", func() { - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 1000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - - playing, err := tracker.GetNowPlaying(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(HaveLen(1)) - Expect(playing[0].State).To(Equal("playing")) - Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(1000))) - }) - - It("keeps the current session when a stopped report arrives for a different track", func() { - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - - playing, err := tracker.GetNowPlaying(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(HaveLen(1)) - Expect(playing[0].MediaFile.ID).To(Equal("456")) - Expect(playing[0].State).To(Equal("playing")) - }) - - It("still auto-scrobbles the stopped track when the current session is for a different track", func() { - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(track.PlayCount).To(Equal(int64(1))) - }) - - It("does not dispatch NowPlaying from an ignored out-of-order starting report", func() { - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 60000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - fake.nowPlayingCalled.Store(false) - - err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) - }) - - It("never lets a concurrent starting report downgrade the playing session", func() { - ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile()} - for i := range 20 { - raceClientId := fmt.Sprintf("race-client-%d", i) - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - defer GinkgoRecover() - _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: raceClientId, - }) - }() - go func() { - defer wg.Done() - defer GinkgoRecover() - _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: raceClientId, - }) - }() - wg.Wait() - info, err := tracker.playMap.Get(raceClientId) - Expect(err).ToNot(HaveOccurred()) - Expect(info.State).To(Equal("playing"), "iteration %d", i) - } - }) - - It("does NOT forward a stopped report for a different track to playback reporters", func() { - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) - fake.PlaybackReportCalled.Store(false) - fake.LastPlaybackReport.Store(nil) - - err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - - Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) - }) - }) - Describe("external scrobbler dispatch", func() { It("dispatches NowPlaying on starting", func() { fake.nowPlayingCalled.Store(false) @@ -1095,14 +715,14 @@ var _ = Describe("PlayTracker", func() { Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) - It("still dispatches NowPlaying when ignoreScrobble=true", func() { + It("does NOT dispatch 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()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) It("does NOT dispatch when ScrobbleEnabled=false", func() { @@ -1455,25 +1075,7 @@ type fakeScrobbler struct { position atomic.Int32 LastScrobble atomic.Pointer[Scrobble] LastPlaybackReport atomic.Pointer[PlaybackSession] - err atomic.Pointer[error] - scrobbleAttempts atomic.Int32 -} - -// SetError sets the error returned by IsAuthorized/NowPlaying/Scrobble/PlaybackReport. -func (f *fakeScrobbler) SetError(err error) { - f.err.Store(&err) -} - -func (f *fakeScrobbler) getError() error { - if e := f.err.Load(); e != nil { - return *e - } - return nil -} - -// ScrobbleAttempts returns how many times Scrobble was called. -func (f *fakeScrobbler) ScrobbleAttempts() int32 { - return f.scrobbleAttempts.Load() + Error error } func (f *fakeScrobbler) GetNowPlayingCalled() bool { @@ -1487,25 +1089,18 @@ 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() } func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { - return f.getError() == nil && f.Authorized + return f.Error == nil && f.Authorized } func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { f.nowPlayingCalled.Store(true) - if err := f.getError(); err != nil { - return err + if f.Error != nil { + return f.Error } f.userID.Store(&userId) // Capture username from context (this is what plugin scrobblers do) @@ -1525,31 +1120,21 @@ 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) - f.scrobbleAttempts.Add(1) - if err := f.getError(); err != nil { - return err + if f.Error != nil { + return f.Error } return nil } func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { f.PlaybackReportCalled.Store(true) - if err := f.getError(); err != nil { - return err + if f.Error != nil { + return f.Error } - f.userID.Store(new(info.UserId)) + uid := info.UserId + f.userID.Store(&uid) f.LastPlaybackReport.Store(&info) return nil } diff --git a/core/scrobbler/playbackreport_worker.go b/core/scrobbler/playbackreport_worker.go index 799dac616..78ca6e0f7 100644 --- a/core/scrobbler/playbackreport_worker.go +++ b/core/scrobbler/playbackreport_worker.go @@ -6,14 +6,13 @@ import ( "github.com/navidrome/navidrome/log" ) -func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession, filtered bool) { +func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) { p.prMu.Lock() defer p.prMu.Unlock() ctx = context.WithoutCancel(ctx) p.prQueue = append(p.prQueue, playbackReportEntry{ - ctx: ctx, - info: info, - filtered: filtered, + ctx: ctx, + info: info, }) p.sendPlaybackReportSignal() } @@ -45,16 +44,12 @@ func (p *playTracker) playbackReportWorker() { allScrobblers := p.getActiveScrobblers() for _, entry := range entries { - p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers, entry.filtered) + p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers) } } } -func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler, filtered bool) { - if filtered { - log.Debug(ctx, "Ignoring external PlaybackReport for filtered track", "track", info.MediaFile.Title, "state", info.State) - return - } +func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) { for name, s := range allScrobblers { if !s.IsAuthorized(ctx, info.UserId) { continue diff --git a/core/share.go b/core/share.go index 33c8996ef..a6d06a018 100644 --- a/core/share.go +++ b/core/share.go @@ -2,16 +2,14 @@ package core import ( "context" - "fmt" - "slices" "strings" "time" "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/nanoid" "github.com/navidrome/navidrome/utils/slice" @@ -20,24 +18,22 @@ import ( type Share interface { Load(ctx context.Context, id string) (*model.Share, error) - Repository() model.ShareRepository + NewRepository(ctx context.Context) rest.Repository } func NewShare(ds model.DataStore) Share { return &shareService{ - ds: ds, - repo: &shareRepositoryWrapper{ShareRepository: ds.Share(), ds: ds}, + ds: ds, } } type shareService struct { - ds model.DataStore - repo *shareRepositoryWrapper + ds model.DataStore } func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error) { - repo := s.ds.Share() - share, err := repo.Get(ctx, id) + repo := s.ds.Share(ctx) + share, err := repo.Get(id) if err != nil { return nil, err } @@ -45,32 +41,43 @@ 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 = new(time.Now()) + share.LastVisitedAt = P(time.Now()) share.VisitCount++ - err = repo.Update(ctx, id, *share, "last_visited_at", "visit_count") + err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count") if err != nil { log.Warn(ctx, "Could not increment visit count for share", "share", share.ID) } return share, nil } -func (s *shareService) Repository() model.ShareRepository { - return s.repo +func (s *shareService) NewRepository(ctx context.Context) rest.Repository { + repo := s.ds.Share(ctx) + wrapper := &shareRepositoryWrapper{ + ctx: ctx, + ShareRepository: repo, + Repository: repo.(rest.Repository), + Persistable: repo.(rest.Persistable), + ds: s.ds, + } + return wrapper } type shareRepositoryWrapper struct { model.ShareRepository - ds model.DataStore + rest.Repository + rest.Persistable + ctx context.Context + ds model.DataStore } -func (r *shareRepositoryWrapper) newId(ctx context.Context) (string, error) { +func (r *shareRepositoryWrapper) newId() (string, error) { for { id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10) if err != nil { return "", err } - exists, err := r.Exists(ctx, id) + exists, err := r.Exists(id) if err != nil { return "", err } @@ -80,111 +87,93 @@ func (r *shareRepositoryWrapper) newId(ctx context.Context) (string, error) { } } -func (r *shareRepositoryWrapper) Save(ctx context.Context, s *model.Share) (string, error) { - // Owner is always the caller; never trust a client-supplied UserID, as it - // determines the library-access context used to resolve the share contents. - if user, ok := request.UserFrom(ctx); ok { - s.UserID = user.ID - } - id, err := r.newId(ctx) +func (r *shareRepositoryWrapper) Save(entity any) (string, error) { + s := entity.(*model.Share) + id, err := r.newId() if err != nil { return "", err } s.ID = id if V(s.ExpiresAt).IsZero() { - s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) + s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration)) } - s.ResourceType, err = r.resourceType(ctx, s.ResourceIDs) + firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0] + v, err := model.GetEntityByID(r.ctx, r.ds, firstId) if err != nil { return "", err } - switch s.ResourceType { - case "artist": - s.Contents = r.contentsLabelFromArtist(ctx, s.ID, s.ResourceIDs) - case "album": - s.Contents = r.contentsLabelFromAlbums(ctx, s.ID, s.ResourceIDs) - case "playlist": - s.Contents = r.contentsLabelFromPlaylist(ctx, s.ID, s.ResourceIDs) - case "media_file": - s.Contents = r.contentsLabelFromMediaFiles(ctx, s.ID, s.ResourceIDs) + switch v.(type) { + case *model.Artist: + s.ResourceType = "artist" + s.Contents = r.contentsLabelFromArtist(s.ID, s.ResourceIDs) + case *model.Album: + s.ResourceType = "album" + s.Contents = r.contentsLabelFromAlbums(s.ID, s.ResourceIDs) + case *model.Playlist: + s.ResourceType = "playlist" + s.Contents = r.contentsLabelFromPlaylist(s.ID, s.ResourceIDs) + case *model.MediaFile: + s.ResourceType = "media_file" + s.Contents = r.contentsLabelFromMediaFiles(s.ID, s.ResourceIDs) + default: + log.Error(r.ctx, "Invalid Resource ID", "id", firstId) + return "", model.ErrNotFound } s.Contents = str.TruncateRunes(s.Contents, 30, "...") - return r.ShareRepository.Save(ctx, s) + id, err = r.Persistable.Save(s) + return id, err } -var shareableKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindMediaFileArtwork} - -// resourceType resolves every ID as the current user, so an entity they cannot see cannot -// ride along behind a valid first one, and requires all IDs to be of the same kind. -func (r *shareRepositoryWrapper) resourceType(ctx context.Context, resourceIDs string) (string, error) { - resourceType := "" - for _, id := range strings.Split(resourceIDs, ",") { - kind, err := model.GetEntityKindByID(ctx, r.ds, id) - if err != nil { - return "", err - } - if !slices.Contains(shareableKinds, kind) { - log.Error(ctx, "Invalid Resource ID", "id", id) - return "", model.ErrNotFound - } - if resourceType != "" && kind.String() != resourceType { - return "", fmt.Errorf("%w: share mixes %s and %s resources", model.ErrValidation, resourceType, kind) - } - resourceType = kind.String() - } - return resourceType, nil -} - -func (r *shareRepositoryWrapper) Update(ctx context.Context, id string, entity model.Share, _ ...string) error { +func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error { cols := []string{"description", "downloadable"} // TODO Better handling of Share expiration - if !V(entity.ExpiresAt).IsZero() { + if !V(entity.(*model.Share).ExpiresAt).IsZero() { cols = append(cols, "expires_at") } - return r.ShareRepository.Update(ctx, id, entity, cols...) + return r.Persistable.Update(id, entity, cols...) } -func (r *shareRepositoryWrapper) contentsLabelFromArtist(ctx context.Context, shareID string, ids string) string { +func (r *shareRepositoryWrapper) contentsLabelFromArtist(shareID string, ids string) string { idList := strings.SplitN(ids, ",", 2) - a, err := r.ds.Artist().Get(ctx, idList[0]) + a, err := r.ds.Artist(r.ctx).Get(idList[0]) if err != nil { - log.Error(ctx, "Error retrieving artist name for share", "share", shareID, err) + log.Error(r.ctx, "Error retrieving artist name for share", "share", shareID, err) return "" } return a.Name } -func (r *shareRepositoryWrapper) contentsLabelFromAlbums(ctx context.Context, shareID string, ids string) string { +func (r *shareRepositoryWrapper) contentsLabelFromAlbums(shareID string, ids string) string { idList := strings.Split(ids, ",") - all, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}}) + all, err := r.ds.Album(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}}) if err != nil { - log.Error(ctx, "Error retrieving album names for share", "share", shareID, err) + log.Error(r.ctx, "Error retrieving album names for share", "share", shareID, err) return "" } names := slice.Map(all, func(a model.Album) string { return a.Name }) return strings.Join(names, ", ") } -func (r *shareRepositoryWrapper) contentsLabelFromPlaylist(ctx context.Context, shareID string, id string) string { - pls, err := r.ds.Playlist().Get(ctx, id) +func (r *shareRepositoryWrapper) contentsLabelFromPlaylist(shareID string, id string) string { + pls, err := r.ds.Playlist(r.ctx).Get(id) if err != nil { - log.Error(ctx, "Error retrieving album names for share", "share", shareID, err) + log.Error(r.ctx, "Error retrieving album names for share", "share", shareID, err) return "" } return pls.Name } -func (r *shareRepositoryWrapper) contentsLabelFromMediaFiles(ctx context.Context, shareID string, ids string) string { +func (r *shareRepositoryWrapper) contentsLabelFromMediaFiles(shareID string, ids string) string { idList := strings.Split(ids, ",") - mfs, err := r.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.And{ + mfs, err := r.ds.MediaFile(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.And{ squirrel.Eq{"media_file.id": idList}, squirrel.Eq{"missing": false}, }}) if err != nil { - log.Error(ctx, "Error retrieving media files for share", "share", shareID, err) + log.Error(r.ctx, "Error retrieving media files for share", "share", shareID, err) return "" } diff --git a/core/share_test.go b/core/share_test.go index 8ce2bf270..475d40ec9 100644 --- a/core/share_test.go +++ b/core/share_test.go @@ -5,7 +5,6 @@ import ( "github.com/deluan/rest" "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" @@ -14,90 +13,69 @@ import ( var _ = Describe("Share", func() { var ds model.DataStore var share Share - var mockedRepo rest.Persistable[model.Share] + var mockedRepo rest.Persistable ctx := context.Background() BeforeEach(func() { ds = &tests.MockDataStore{} - mockedRepo = ds.Share().(rest.Persistable[model.Share]) + mockedRepo = ds.Share(ctx).(rest.Persistable) share = NewShare(ds) }) Describe("NewRepository", func() { - var repo rest.Persistable[model.Share] + var repo rest.Persistable BeforeEach(func() { - repo = share.Repository().(rest.Persistable[model.Share]) - _ = ds.Album().Put(ctx, &model.Album{ID: "123", Name: "Album"}) + repo = share.NewRepository(ctx).(rest.Persistable) + _ = ds.Album(ctx).Put(&model.Album{ID: "123", Name: "Album"}) }) Describe("Save", func() { It("it sets a random ID", func() { entity := &model.Share{Description: "test", ResourceIDs: "123"} - id, err := repo.Save(ctx, entity) + id, err := repo.Save(entity) Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeEmpty()) Expect(entity.ID).To(Equal(id)) }) - It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { - loggedInCtx := request.WithUser(context.Background(), model.User{ID: "logged-in-user"}) - repo := share.Repository().(rest.Persistable[model.Share]) - entity := &model.Share{Description: "test", ResourceIDs: "123", UserID: "victim-user"} - _, err := repo.Save(loggedInCtx, entity) - Expect(err).ToNot(HaveOccurred()) - Expect(entity.UserID).To(Equal("logged-in-user")) - }) - It("does not truncate ASCII labels shorter than 30 characters", func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "Example Media File"}) + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"}) entity := &model.Share{Description: "test", ResourceIDs: "456"} - _, err := repo.Save(ctx, entity) + _, err := repo.Save(entity) Expect(err).ToNot(HaveOccurred()) Expect(entity.Contents).To(Equal("Example Media File")) }) It("truncates ASCII labels longer than 30 characters", func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"}) + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"}) entity := &model.Share{Description: "test", ResourceIDs: "789"} - _, err := repo.Save(ctx, entity) + _, err := repo.Save(entity) Expect(err).ToNot(HaveOccurred()) Expect(entity.Contents).To(Equal("Example Media File But The ...")) }) It("does not truncate CJK labels shorter than 30 runes", func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "青春コンプレックス"}) + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"}) entity := &model.Share{Description: "test", ResourceIDs: "456"} - _, err := repo.Save(ctx, entity) + _, err := repo.Save(entity) Expect(err).ToNot(HaveOccurred()) Expect(entity.Contents).To(Equal("青春コンプレックス")) }) It("truncates CJK labels longer than 30 runes", func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"}) + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"}) entity := &model.Share{Description: "test", ResourceIDs: "789"} - _, err := repo.Save(ctx, entity) + _, err := repo.Save(entity) Expect(err).ToNot(HaveOccurred()) Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で...")) }) - - It("fails when any of the resource IDs does not exist", func() { - entity := &model.Share{Description: "test", ResourceIDs: "123,missing"} - _, err := repo.Save(ctx, entity) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("fails when the resource IDs are of mixed types", func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "Example Media File"}) - entity := &model.Share{Description: "test", ResourceIDs: "123,456"} - _, err := repo.Save(ctx, entity) - Expect(err).To(HaveOccurred()) - }) }) Describe("Update", func() { It("filters out read-only fields", func() { - err := repo.Update(ctx, "id", model.Share{}) + entity := &model.Share{} + err := repo.Update("id", entity) Expect(err).ToNot(HaveOccurred()) Expect(mockedRepo.(*tests.MockShareRepo).Cols).To(ConsistOf("description", "downloadable")) }) diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go index 8645d3d28..19eb69c65 100644 --- a/core/sonic/sonic.go +++ b/core/sonic/sonic.go @@ -46,15 +46,6 @@ func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher } } -// Engine is the sonic-similarity surface the API layers depend on; *Sonic satisfies it. -type Engine interface { - HasProvider() bool - GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) - FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) -} - -var _ Engine = (*Sonic)(nil) - func (s *Sonic) HasProvider() bool { return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0 } @@ -100,7 +91,7 @@ func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) return nil, err } - mf, err := s.ds.MediaFile().Get(ctx, id) + mf, err := s.ds.MediaFile(ctx).Get(id) if err != nil { return nil, fmt.Errorf("getting media file %s: %w", id, err) } @@ -120,11 +111,11 @@ func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count return nil, err } - startMF, err := s.ds.MediaFile().Get(ctx, startID) + startMF, err := s.ds.MediaFile(ctx).Get(startID) if err != nil { return nil, fmt.Errorf("getting start media file %s: %w", startID, err) } - endMF, err := s.ds.MediaFile().Get(ctx, endID) + endMF, err := s.ds.MediaFile(ctx).Get(endID) if err != nil { return nil, fmt.Errorf("getting end media file %s: %w", endID, err) } diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go index 813fceea9..81739b726 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", Artists: []agents.Artist{{Name: "Test Artist"}}}, Similarity: 0.85}, + {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85}, }, } loader.names = []string{"test-plugin"} diff --git a/core/storage/interface.go b/core/storage/interface.go index 02c1d14d9..dc08ca00a 100644 --- a/core/storage/interface.go +++ b/core/storage/interface.go @@ -17,14 +17,6 @@ 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/deviceid_unix.go b/core/storage/local/deviceid_unix.go deleted file mode 100644 index 42e7dac70..000000000 --- a/core/storage/local/deviceid_unix.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build !windows - -package local - -import ( - "io/fs" - "syscall" -) - -// deviceID identifies the filesystem a file lives on, used to key birth time support per mount. -// It is returned opaquely because its width varies by platform, and it is only used as a map key. -func deviceID(fi fs.FileInfo) (any, bool) { - st, ok := fi.Sys().(*syscall.Stat_t) - if !ok { - return nil, false - } - return st.Dev, true -} diff --git a/core/storage/local/deviceid_windows.go b/core/storage/local/deviceid_windows.go deleted file mode 100644 index f1b44be4d..000000000 --- a/core/storage/local/deviceid_windows.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build windows - -package local - -import "io/fs" - -// deviceID has no Windows equivalent, and none is needed: birth time comes straight from FileInfo. -func deviceID(fs.FileInfo) (any, bool) { return nil, false } diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 686838565..5384581e0 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -6,7 +6,6 @@ import ( "net/url" "os" "path/filepath" - "sync" "sync/atomic" "time" @@ -55,25 +54,12 @@ 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, root: path}, nil + return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil } type localFS struct { fs.FS extractor Extractor - root string - // devices whose statx never reports a birth time (NFS, rclone/FUSE), so we ask each only once - noBirthTime sync.Map -} - -// 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) { @@ -87,11 +73,7 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { if err != nil { return nil, err } - v.FileInfo = localFileInfo{ - FileInfo: info, - path: filepath.Join(lfs.root, filepath.FromSlash(path)), - noBirthTime: &lfs.noBirthTime, - } + v.FileInfo = localFileInfo{info} res[path] = v } } @@ -102,46 +84,15 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { // with metadata.FileInfo type localFileInfo struct { fs.FileInfo - path string - noBirthTime *sync.Map } func (lfi localFileInfo) BirthTime() time.Time { if ts := times.Get(lfi.FileInfo); ts.HasBirthTime() { return ts.BirthTime() } - if bt, ok := lfi.statxBirthTime(); ok { - return bt - } return time.Now() } -// statxBirthTime reads the birth time from the path, which on Linux is the only way to get it. -// Filesystems that never report one are remembered per device, so a scan asks each only once. -func (lfi localFileInfo) statxBirthTime() (time.Time, bool) { - if lfi.path == "" { - return time.Time{}, false - } - dev, hasDev := deviceID(lfi.FileInfo) - memo := lfi.noBirthTime - if hasDev && memo != nil { - if _, skip := memo.Load(dev); skip { - return time.Time{}, false - } - } - ts, err := times.Stat(lfi.path) - if err != nil { - return time.Time{}, false - } - if ts.HasBirthTime() { - return ts.BirthTime(), true - } - if hasDev && memo != nil { - memo.Store(dev, struct{}{}) - } - return time.Time{}, false -} - func init() { storage.Register(storage.LocalSchemaID, newLocalStorage) } diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index 3ed6d6fd9..aef89cdd5 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -6,15 +6,14 @@ import ( "os" "path/filepath" "runtime" - "sync" "time" - "github.com/djherbis/times" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "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" ) @@ -46,13 +45,16 @@ 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 := storage.LocalPathToURL(tempDir) + u, err := url.Parse("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) + storage := newLocalStorage(*u) localStorage := storage.(*localStorage) Expect(localStorage.u.Scheme).To(Equal("file")) @@ -92,10 +94,10 @@ var _ = Describe("LocalStorage", func() { err = os.Symlink(realDir, linkDir) Expect(err).ToNot(HaveOccurred()) - u, err := storage.LocalPathToURL(linkDir) + u, err := url.Parse("file://" + linkDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) + storage := newLocalStorage(*u) localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) @@ -108,10 +110,10 @@ var _ = Describe("LocalStorage", func() { // Use a non-existent path to trigger symlink resolution failure nonExistentPath := filepath.Join(tempDir, "non-existent") - u, err := storage.LocalPathToURL(nonExistentPath) + u, err := url.Parse("file://" + nonExistentPath) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) + storage := newLocalStorage(*u) localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) @@ -135,9 +137,7 @@ var _ = Describe("LocalStorage", func() { localStorage, ok := storage.(*localStorage) Expect(ok).To(BeTrue()) - // 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"))) + Expect(localStorage.u.Path).To(Equal("C:/music")) }) }) @@ -159,10 +159,10 @@ var _ = Describe("LocalStorage", func() { It("falls back to the default extractor instead of crashing", func() { conf.Server.Scanner.Extractor = "nonexistent-extractor" - u, err := storage.LocalPathToURL(tempDir) + u, err := url.Parse("file://" + 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,13 +171,16 @@ 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 := storage.LocalPathToURL(tempDir) + u, err := url.Parse("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) + storage := newLocalStorage(*u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) Expect(musicFS).ToNot(BeNil()) @@ -190,10 +193,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 := storage.LocalPathToURL(nonExistentPath) + u, err := url.Parse("file://" + nonExistentPath) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) + storage := newLocalStorage(*u) _, err = storage.FS() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring(nonExistentPath)) @@ -201,82 +204,11 @@ 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) @@ -303,9 +235,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = expectedInfo - u, err := storage.LocalPathToURL(tempDir) + u, err := url.Parse("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) + storage := newLocalStorage(*u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -327,9 +259,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = incompleteInfo - u, err := storage.LocalPathToURL(tempDir) + u, err := url.Parse("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) + storage := newLocalStorage(*u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -356,9 +288,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["non-existent.mp3"] = incompleteInfo - u, err := storage.LocalPathToURL(tempDir) + u, err := url.Parse("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) + storage := newLocalStorage(*u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -371,9 +303,9 @@ var _ = Describe("LocalStorage", func() { It("should return the extractor error", func() { testExtractor.err = &extractorError{message: "extractor failed"} - u, err := storage.LocalPathToURL(tempDir) + u, err := url.Parse("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) + storage := newLocalStorage(*u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -402,9 +334,9 @@ var _ = Describe("LocalStorage", func() { testExtractor.results["test.mp3"] = info1 testExtractor.results["test2.mp3"] = info2 - u, err := storage.LocalPathToURL(tempDir) + u, err := url.Parse("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) + storage := newLocalStorage(*u) musicFS, err := storage.FS() Expect(err).ToNot(HaveOccurred()) @@ -442,37 +374,6 @@ var _ = Describe("LocalStorage", func() { // Should be around the current time (within last few minutes) Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute)) }) - - It("reads the birth time from the path, not the time of the call", func() { - // On Linux, birth time is only available via statx(2) on the path. - lfi := localFileInfo{FileInfo: fileInfo, path: testFile} - time.Sleep(300 * time.Millisecond) - Expect(lfi.BirthTime()).To(BeTemporally("<", time.Now().Add(-200*time.Millisecond))) - }) - - It("does not remember filesystems that do report a birth time", func() { - memo := &sync.Map{} - lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} - lfi.BirthTime() - - count := 0 - memo.Range(func(_, _ any) bool { count++; return true }) - Expect(count).To(BeZero()) - }) - - It("skips statx on filesystems already known to have none", func() { - if times.Get(fileInfo).HasBirthTime() { - Skip("this platform reports birth time from FileInfo, so statx is never called") - } - dev, ok := deviceID(fileInfo) - Expect(ok).To(BeTrue()) - - memo := &sync.Map{} - memo.Store(dev, struct{}{}) - lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} - time.Sleep(300 * time.Millisecond) - Expect(lfi.BirthTime()).To(BeTemporally("~", time.Now(), 100*time.Millisecond)) - }) }) It("should delegate all other FileInfo methods", func() { @@ -489,8 +390,9 @@ 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(tempDir) + storage, err := storage.For("file://" + tempDir) Expect(err).ToNot(HaveOccurred()) Expect(storage).To(BeAssignableToTypeOf(&localStorage{})) }) diff --git a/core/storage/local/watcher.go b/core/storage/local/watcher.go index ef404d497..1b8a4e0c8 100644 --- a/core/storage/local/watcher.go +++ b/core/storage/local/watcher.go @@ -3,7 +3,6 @@ package local import ( "context" "errors" - "fmt" "path/filepath" "strings" @@ -19,18 +18,22 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) { return nil, errors.New("watcher already started") } input := make(chan notify.EventInfo, 500) - libPath := filepath.Join(s.u.Path, "...") - log.Debug(ctx, "Starting watcher", "lib", libPath) - if err := notify.Watch(libPath, input, WatchEvents); err != nil { - s.watching.Store(false) - return nil, fmt.Errorf("starting watcher on %s: %w", libPath, err) - } - output := make(chan string, 500) + + started := make(chan struct{}) go func() { defer close(input) defer close(output) + + libPath := filepath.Join(s.u.Path, "...") + log.Debug(ctx, "Starting watcher", "lib", libPath) + err := notify.Watch(libPath, input, WatchEvents) + if err != nil { + log.Error("Error starting watcher", "lib", libPath, err) + return + } defer notify.Stop(input) + close(started) // signals the main goroutine we have started for { select { @@ -46,5 +49,9 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) { } } }() + select { + case <-started: + case <-ctx.Done(): + } return output, nil } diff --git a/core/storage/local/watcher_test.go b/core/storage/local/watcher_test.go index 37387c76a..8d2d31367 100644 --- a/core/storage/local/watcher_test.go +++ b/core/storage/local/watcher_test.go @@ -137,18 +137,3 @@ type noopExtractor struct{} func (s noopExtractor) Parse(files ...string) (map[string]metadata.Info, error) { return nil, nil } func (s noopExtractor) Version() string { return "0" } - -var _ = Describe("Watcher.Start", func() { - It("returns an error instead of hanging when the path cannot be watched", func() { - local.RegisterExtractor("noop", func(fs fs.FS, path string) local.Extractor { return noopExtractor{} }) - conf.Server.Scanner.Extractor = "noop" - ls, err := storage.For(filepath.Join(GinkgoT().TempDir(), "does-not-exist")) - Expect(err).ToNot(HaveOccurred()) - lsw := ls.(storage.Watcher) - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - _, err = lsw.Start(ctx) - Expect(err).To(HaveOccurred()) - }) -}) diff --git a/core/storage/storage.go b/core/storage/storage.go index 242965c99..b9fceb1fd 100644 --- a/core/storage/storage.go +++ b/core/storage/storage.go @@ -25,27 +25,6 @@ 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. @@ -55,22 +34,24 @@ 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 { - 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 + 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, "/") } + 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 03904d086..32fbac413 100644 --- a/core/storage/storage_test.go +++ b/core/storage/storage_test.go @@ -4,9 +4,9 @@ import ( "net/url" "os" "path/filepath" - "runtime" "testing" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -55,16 +55,13 @@ var _ = Describe("Storage", func() { Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp")) }) It("should return a file implementation for a relative folder", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage)") s, err := For("tmp") Expect(err).ToNot(HaveOccurred()) cwd, _ := os.Getwd() Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{})) - u := s.(*fakeLocalStorage).u - Expect(u.Scheme).To(Equal("file")) - // On Windows the drive letter lands in u.Host, so re-join it with - // u.Path (as newLocalStorage does) to keep the assertion OS-independent. - got := filepath.Join(u.Host, filepath.FromSlash(u.Path)) - Expect(got).To(Equal(filepath.Join(cwd, "tmp"))) + Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file")) + Expect(s.(*fakeLocalStorage).u.Path).To(Equal(filepath.Join(cwd, "tmp"))) }) It("should return error if schema is unregistered", func() { _, err := For("webdav:///tmp") @@ -86,43 +83,6 @@ 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 56d163324..28bff75c4 100644 --- a/core/stream/codec.go +++ b/core/stream/codec.go @@ -43,17 +43,14 @@ func normalizeSourceSampleRate(sampleRate int, codec string) int { return sampleRate } -// 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 { +// 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 { return 24 } - return srcBitDepth + return bitDepth } // codecFixedOutputSampleRate returns the mandatory output sample rate for codecs diff --git a/core/stream/decider.go b/core/stream/decider.go index 38839cdf0..d6e48497c 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -12,7 +12,6 @@ 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 @@ -23,7 +22,6 @@ type TranscodeDecider interface { CreateTranscodeParams(decision *TranscodeDecision) (string, error) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request - ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request } func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider { @@ -144,7 +142,7 @@ func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Deta sd.Codec = mf.AudioCodec() sd.Bitrate = mf.BitRate sd.SampleRate = mf.SampleRate - sd.BitDepth = gg.V(mf.BitDepth) + sd.BitDepth = mf.BitDepth sd.Channels = mf.Channels } sd.IsLossless = isLosslessFormat(sd.Codec) @@ -270,7 +268,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: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless), + BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec), IsLossless: targetIsLossless, } if ts.Codec == "" { @@ -312,7 +310,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai // It checks the DB first (for user-customized values), then falls back to // the built-in defaults, and finally to fallbackBitrate. func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int { - if t, err := ds.Transcoding().FindByFormat(ctx, format); err == nil && t.DefaultBitRate > 0 { + if t, err := ds.Transcoding(ctx).FindByFormat(format); err == nil && t.DefaultBitRate > 0 { return t.DefaultBitRate } for _, dt := range consts.DefaultTranscodings { @@ -327,7 +325,7 @@ func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string // It checks the DB first (for user-customized commands), then falls back to // the built-in default command. Returns "" if the format is unknown. func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string { - t, err := ds.Transcoding().FindByFormat(ctx, format) + t, err := ds.Transcoding(ctx).FindByFormat(format) if err == nil && t.Command != "" { return t.Command } @@ -447,7 +445,7 @@ func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) } mf.ProbeData = string(data) - if err := s.ds.MediaFile().UpdateProbeData(ctx, mf.ID, mf.ProbeData); err != nil { + if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil { log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err) // Don't fail the decision — we have the data in memory } diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 01fef1249..f74953258 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -12,7 +12,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" ) @@ -24,7 +23,7 @@ func withProbe(mf *model.MediaFile) *model.MediaFile { Codec: mf.AudioCodec(), BitRate: mf.BitRate, SampleRate: mf.SampleRate, - BitDepth: gg.V(mf.BitDepth), + BitDepth: mf.BitDepth, Channels: mf.Channels, } data, _ := json.Marshal(probe) @@ -244,7 +243,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, // kbps DirectPlayProfiles: []DirectPlayProfile{ @@ -279,7 +278,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "mp3", Protocol: ProtocolHTTP}, @@ -332,7 +331,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, DirectPlayProfiles: []DirectPlayProfile{ @@ -352,7 +351,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: new(1)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1}) ci := &ClientInfo{ MaxAudioBitrate: 1000, DirectPlayProfiles: []DirectPlayProfile{ @@ -370,7 +369,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: new(24)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -527,7 +526,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: new(24)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, @@ -574,7 +573,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -597,7 +596,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: new(24)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -620,7 +619,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: new(24)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -643,7 +642,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: new(24)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -656,46 +655,8 @@ var _ = Describe("Decider", func() { Expect(decision.TargetBitDepth).To(Equal(24)) }) - It("omits bit depth when transcoding to a lossy format", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 320, - TranscodingProfiles: []Profile{ - {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.BitDepth).To(BeZero()) - Expect(decision.TargetBitDepth).To(BeZero()) - }) - - It("ignores audioBitdepth limitation when transcoding to a lossy format", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 320, - TranscodingProfiles: []Profile{ - {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "opus", - Limitations: []Limitation{ - {Name: LimitationAudioBitdepth, Comparison: ComparisonGreaterThanEqual, Values: []string{"32"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.BitDepth).To(BeZero()) - }) - It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -719,7 +680,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: new(1)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -733,13 +694,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)) - // MP3 is lossy: no bit depth on the transcoded stream - Expect(decision.TranscodeStream.BitDepth).To(BeZero()) - Expect(decision.TargetBitDepth).To(BeZero()) + // DSD 1-bit → 24-bit PCM + Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) + Expect(decision.TargetBitDepth).To(Equal(24)) }) 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: new(1)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -758,7 +719,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: new(1)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -785,7 +746,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: new(1)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, @@ -814,7 +775,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -830,7 +791,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -845,7 +806,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -860,7 +821,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -879,7 +840,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: new(16)} + mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} probe := ffmpeg.AudioProbeResult{ Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2, } @@ -923,7 +884,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 128, TranscodingProfiles: []Profile{ @@ -940,7 +901,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: new(24)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 128, TranscodingProfiles: []Profile{ @@ -956,7 +917,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, TranscodingProfiles: []Profile{ @@ -974,7 +935,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 256, TranscodingProfiles: []Profile{ @@ -991,7 +952,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: new(24)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -1005,7 +966,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -1021,7 +982,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: new(1)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) ci := &ClientInfo{ MaxTranscodingAudioBitrate: 320, TranscodingProfiles: []Profile{ @@ -1064,7 +1025,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: new(24), Duration: 300.5, Size: 50000000}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000}) ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{ {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, @@ -1097,7 +1058,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ Name: "TestClient", DirectPlayProfiles: []DirectPlayProfile{ @@ -1113,7 +1074,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, @@ -1126,7 +1087,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ TranscodingProfiles: []Profile{ {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, @@ -1144,82 +1105,6 @@ var _ = Describe("Decider", func() { }) }) - Context("Player-forced format", func() { - symfonium := func() *ClientInfo { - return &ClientInfo{ - Name: "Symfonium", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp3", "flac", "ogg"}, Protocols: []string{ProtocolHTTP}}, - }, - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - } - } - - It("direct plays a flac source forced to flac", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := symfonium() - Expect(ci.ForceFormat("flac")).To(BeTrue()) - - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("still transcodes a 24-bit flac when the client caps bit depth", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 4600, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - ci := symfonium() - ci.CodecProfiles = []CodecProfile{{ - Type: CodecProfileTypeAudio, Name: "flac", - Limitations: []Limitation{{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}}, - }} - Expect(ci.ForceFormat("flac")).To(BeTrue()) - - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) - }) - - It("still transcodes a 320 mp3 forced to mp3 at a lower bitrate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - ci := symfonium() - Expect(ci.ForceFormat("mp3")).To(BeTrue()) - ci.CapBitrate(192) - - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetBitrate).To(Equal(192)) - }) - - It("direct plays a 128 mp3 forced to mp3 at a higher bitrate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) - ci := symfonium() - Expect(ci.ForceFormat("mp3")).To(BeTrue()) - ci.CapBitrate(192) - - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("transcodes a flac source forced to mp3", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := symfonium() - Expect(ci.ForceFormat("mp3")).To(BeTrue()) - - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - }) - }) }) Describe("ensureProbed", func() { diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index 5b732c3e2..9dd6179a0 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, playerMaxBitRate int) *ClientInfo { +func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo { ci := &ClientInfo{Name: "legacy"} // Determine target format for transcoding @@ -22,10 +22,6 @@ 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 != "" { @@ -67,14 +63,39 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile return req } - playerMaxBitRate := 0 - if player, ok := request.PlayerFrom(ctx); ok { - playerMaxBitRate = player.MaxBitRate + clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) + + // 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 + clientInfo = &modified + log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + } } - clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate) - if resolved, ok := s.resolve(ctx, mf, clientInfo, offset); ok { - return resolved + decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) + if err != nil { + log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) + req.Format = "raw" + return req + } + + if decision.CanDirectPlay { + req.Format = "raw" + return req + } + + if decision.CanTranscode { + req.Format = decision.TargetFormat + req.BitRate = decision.TargetBitrate + req.SampleRate = decision.TargetSampleRate + req.BitDepth = decision.TargetBitDepth + req.Channels = decision.TargetChannels + return req } // No compatible profile for the requested format — retry with DefaultDownsamplingFormat @@ -90,47 +111,3 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile req.Format = "raw" return req } - -// ResolveClientRequest resolves a stream request for a client that declared its own direct play -// and transcoding profiles, falling back to raw when none fits. -func (s *deciderService) ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request { - if req, ok := s.resolve(ctx, mf, clientInfo, offset); ok { - return req - } - return Request{Format: "raw", Offset: offset} -} - -// resolve applies the server-side player overrides to clientInfo and maps the decision to a -// Request. ok is false when no profile fits. -func (s *deciderService) resolve(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) (Request, bool) { - req := Request{Offset: offset} - if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - clientInfo = applyServerOverride(ctx, clientInfo, &trc) - } 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) - } - } - - decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) - if err != nil { - log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) - req.Format = "raw" - return req, true - } - switch { - case decision.CanDirectPlay: - req.Format = "raw" - case decision.CanTranscode: - req.Format = decision.TargetFormat - req.BitRate = decision.TargetBitrate - req.SampleRate = decision.TargetSampleRate - req.BitDepth = decision.TargetBitDepth - req.Channels = decision.TargetChannels - default: - return req, false - } - return req, true -} diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index ad3417faf..ce7b38650 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, 0) + ci := buildLegacyClientInfo(mf, "mp3", 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, 0) + ci := buildLegacyClientInfo(mf, "opus", 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, 0) + ci := buildLegacyClientInfo(mf, "flac", 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, 0) + ci := buildLegacyClientInfo(mf, "mp3", 192) 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, 0) + ci := buildLegacyClientInfo(mf, "", 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, 0) + ci := buildLegacyClientInfo(mf, "", 128) 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, 0) + ci := buildLegacyClientInfo(mf, "", 960) Expect(ci.DirectPlayProfiles).To(HaveLen(1)) Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) @@ -100,51 +100,6 @@ 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() { @@ -183,7 +138,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "opus", 0, 0) @@ -192,7 +147,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0) @@ -214,7 +169,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "", 128, 0) @@ -224,7 +179,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "opus", 128, 30) @@ -304,7 +259,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) decider := svc.(*deciderService) @@ -315,7 +270,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) decider := svc.(*deciderService) @@ -334,33 +289,6 @@ 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() { @@ -404,7 +332,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: new(16)}) + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) decider := svc.(*deciderService) req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0) @@ -414,72 +342,3 @@ var _ = Describe("ResolveRequest", func() { }) }) }) - -var _ = Describe("ResolveClientRequest", func() { - var ( - svc TranscodeDecider - ctx context.Context - ) - mp3Target := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}} - - BeforeEach(func() { - ctx = GinkgoT().Context() - ds := &tests.MockDataStore{ - MockedProperty: &tests.MockedPropertyRepo{}, - MockedTranscoding: &tests.MockTranscodingRepo{}, - } - auth.Init(ds) - svc = NewTranscodeDecider(ds, tests.NewMockFFmpeg("")) - }) - - It("direct plays a source matching a profile through container aliases", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}}}, - TranscodingProfiles: mp3Target, - } - - req := svc.ResolveClientRequest(ctx, mf, ci, 5) - - Expect(req.Format).To(Equal("raw")) - Expect(req.Offset).To(Equal(5)) - }) - - It("transcodes to the client's transcoding profile when no direct play profile matches", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp3"}}}, - TranscodingProfiles: mp3Target, - } - - Expect(svc.ResolveClientRequest(ctx, mf, ci, 0).Format).To(Equal("mp3")) - }) - - It("transcodes a direct-playable source over the bitrate cap to the client's target", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := &ClientInfo{ - MaxAudioBitrate: 128, - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, - TranscodingProfiles: mp3Target, - } - - req := svc.ResolveClientRequest(ctx, mf, ci, 0) - - Expect(req.Format).To(Equal("mp3")) - Expect(req.BitRate).To(Equal(128)) - }) - - It("applies the player's MaxBitRate cap", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ctx = request.WithPlayer(ctx, model.Player{MaxBitRate: 192}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, - TranscodingProfiles: mp3Target, - } - - req := svc.ResolveClientRequest(ctx, mf, ci, 0) - - Expect(req.Format).To(Equal("mp3")) - Expect(req.BitRate).To(Equal(192)) - }) -}) diff --git a/core/stream/limiter.go b/core/stream/limiter.go deleted file mode 100644 index 622fe21cc..000000000 --- a/core/stream/limiter.go +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index d278d47c8..000000000 --- a/core/stream/limiter_test.go +++ /dev/null @@ -1,186 +0,0 @@ -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 c250b0c4e..de03b4d2f 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -2,7 +2,6 @@ package stream import ( "context" - "errors" "fmt" "io" "mime" @@ -29,19 +28,13 @@ 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, - limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser), - } + return &mediaStreamer{ds: ds, transcoder: t, cache: cache} } type mediaStreamer struct { ds model.DataStore transcoder ffmpeg.FFmpeg cache cache.FileCache - limiter TranscodeLimiter } type streamJob struct { @@ -111,12 +104,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req } r, err := ms.cache.Get(ctx, job) if err != nil { - // 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) - } + log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err) return nil, err } cached = r.Cached @@ -133,7 +121,7 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req } type Stream struct { - ctx context.Context //nolint:containedctx // stream outlives the call that built it; Read has no ctx + ctx context.Context mf *model.MediaFile bitRate int format string @@ -152,9 +140,8 @@ func (s *Stream) EstimatedContentLength() int { // Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent // (supporting range requests). For non-seekable streams it writes directly and logs any errors. -// Returns the number of bytes written and an error only when it fails with 0 bytes written +// Returns the number of bytes written and an error only when io.Copy fails with 0 bytes written // (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response). -// Once bytes are on the wire it panics with http.ErrAbortHandler instead, aborting the response. // Empty output (0 bytes, no error) is logged but not treated as an error. func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) { if s.Seekable() { @@ -184,8 +171,7 @@ func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Reque w.Header().Del("Content-Length") return 0, fmt.Errorf("sending transcoded file: %w", err) } - // The 200 is already sent, so dropping the connection is the only way to say "truncated". - panic(http.ErrAbortHandler) + return c, nil } if c == 0 { log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+ @@ -231,31 +217,15 @@ func NewTranscodingCache() TranscodingCache { return nil, os.ErrInvalid } - 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. + // Choose the appropriate context based on EnableTranscodingCancellation configuration. + // This is where we decide whether transcoding processes should be cancellable or not. var transcodingCtx context.Context - if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation { + if conf.Server.EnableTranscodingCancellation { + // Use the request context directly, allowing cancellation when client disconnects 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) } @@ -268,17 +238,12 @@ func NewTranscodingCache() TranscodingCache { BitDepth: job.bitDepth, Channels: job.channels, Offset: job.offset, - Duration: job.mf.Duration, }) if err != nil { - release() log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) return nil, os.ErrInvalid } - // 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 + return out, nil }) } @@ -290,16 +255,3 @@ 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 5a4bcd480..1bc21e239 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -1,24 +1,15 @@ package stream_test import ( - "bytes" "context" - "errors" "io" - "net/http" - "net/http/httptest" "os" - "testing/iotest" - "time" - - "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "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" @@ -32,26 +23,25 @@ var _ = Describe("MediaStreamer", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - cacheDir, _ := os.MkdirTemp("", "file_caches") - conf.Server.CacheFolder = conf.NewDir(cacheDir) + conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches") conf.Server.TranscodingCacheSize = "100MB" ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0}, }) testCache := stream.NewTranscodingCache() - Eventually(func() bool { return testCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) + Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue()) streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache) }) AfterEach(func() { - _ = os.RemoveAll(conf.Server.CacheFolder.String()) + _ = os.RemoveAll(conf.Server.CacheFolder) }) Context("NewStream", func() { var mf *model.MediaFile BeforeEach(func() { var err error - mf, err = ds.MediaFile().Get(ctx, "123") + mf, err = ds.MediaFile(ctx).Get("123") Expect(err).ToNot(HaveOccurred()) }) It("returns a seekable stream if format is 'raw'", func() { @@ -70,70 +60,6 @@ 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()) }, 10*time.Second).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()) }, 10*time.Second).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()) }, 10*time.Second).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()) @@ -146,49 +72,4 @@ var _ = Describe("MediaStreamer", func() { Expect(s.Seekable()).To(BeTrue()) }) }) - - Context("Serve", func() { - var mf *model.MediaFile - BeforeEach(func() { - var err error - mf, err = ds.MediaFile().Get(ctx, "123") - Expect(err).ToNot(HaveOccurred()) - }) - - It("keeps empty output a non-error, so callers still reply 200 with an empty body", func() { - s := stream.NewStream(mf, "mp3", 128, io.NopCloser(bytes.NewReader(nil))) - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - n, err := s.Serve(ctx, w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(BeZero()) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("aborts the response when the source fails after sending data", func() { - src := io.NopCloser(io.MultiReader( - bytes.NewReader(bytes.Repeat([]byte("a"), 64*1024)), - iotest.ErrReader(errors.New("transcoder died")), - )) - server := httptest.NewServer(serveHandler(stream.NewStream(mf, "mp3", 128, src))) - DeferCleanup(server.Close) - - resp, err := http.Get(server.URL) - Expect(err).ToNot(HaveOccurred()) - defer resp.Body.Close() - - // A client-side read failure is the only observable proof the response was aborted. - _, err = io.ReadAll(resp.Body) - Expect(err).To(HaveOccurred()) - }) - }) }) - -// Serve runs behind the real server's Recoverer, which must let ErrAbortHandler through. -func serveHandler(s *stream.Stream) http.Handler { - return middleware.Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = s.Serve(r.Context(), w, r) - })) -} diff --git a/core/stream/token.go b/core/stream/token.go index 21a26ca93..24a154b54 100644 --- a/core/stream/token.go +++ b/core/stream/token.go @@ -92,10 +92,17 @@ func paramsFromToken(token jwt.Token) (*params, error) { return &p, nil } -// 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. +// 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). 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/token_test.go b/core/stream/token_test.go index 4f0d8066c..7409a7532 100644 --- a/core/stream/token_test.go +++ b/core/stream/token_test.go @@ -232,16 +232,6 @@ var _ = Describe("Token", func() { _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0) Expect(err).To(MatchError(ErrTokenStale)) }) - - It("rejects a Jellyfin access token", func() { - mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime} - usr := &model.User{ID: "u1", UserName: "johndoe"} - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - - _, err = svc.ResolveRequestFromToken(ctx, tokenStr, mf, 0) - Expect(err).To(MatchError(ErrTokenInvalid)) - }) }) Describe("paramsFromToken", func() { diff --git a/core/stream/types.go b/core/stream/types.go index 300017c01..bd8ce292c 100644 --- a/core/stream/types.go +++ b/core/stream/types.go @@ -40,60 +40,6 @@ 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, but only if the -// client already declares a profile for it. All matching profiles are kept so -// negotiation can still pick among them (e.g. by protocol). Direct play is rebuilt -// from those profiles rather than dropped, since declaring a transcoding profile -// for a format is proof the client can play it. Returns false when unsupported. -func (ci *ClientInfo) ForceFormat(targetFormat string) bool { - if targetFormat == "" { - return false - } - var matched []Profile - var directPlay []DirectPlayProfile - for i := range ci.TranscodingProfiles { - p := &ci.TranscodingProfiles[i] - // matchesContainer is alias-aware, so a forced "oga" (legacy Opus - // target_format) still matches a resolved "opus" profile. - container, format := resolveTargetFormat(p) - if !matchesContainer(format, []string{targetFormat}) { - continue - } - matched = append(matched, *p) - directPlay = append(directPlay, DirectPlayProfile{ - Containers: []string{container}, - AudioCodecs: []string{format}, - Protocols: []string{ProtocolHTTP}, - MaxAudioChannels: p.MaxAudioChannels, - }) - } - if len(matched) == 0 { - return false - } - ci.TranscodingProfiles = matched - ci.DirectPlayProfiles = directPlay - 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 deleted file mode 100644 index 88ad904a7..000000000 --- a/core/stream/types_test.go +++ /dev/null @@ -1,161 +0,0 @@ -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 direct play to the forced format 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(ConsistOf(DirectPlayProfile{ - Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}, - })) - }) - - It("keeps direct play for a source already in the forced format", func() { - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - } - ok := ci.ForceFormat("flac") - Expect(ok).To(BeTrue()) - Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{ - Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{ProtocolHTTP}, - })) - }) - - It("carries the channel limit of the forced profile into direct play", func() { - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, - }, - } - Expect(ci.ForceFormat("flac")).To(BeTrue()) - Expect(ci.DirectPlayProfiles).To(HaveLen(1)) - Expect(ci.DirectPlayProfiles[0].MaxAudioChannels).To(Equal(2)) - }) - - 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/core/user.go b/core/user.go index d67d69dcb..f13e90167 100644 --- a/core/user.go +++ b/core/user.go @@ -15,40 +15,62 @@ type PluginUnloader interface { // User provides business logic for user management with plugin coordination. type User interface { - Repository() rest.Repository[model.User] + NewRepository(ctx context.Context) rest.Repository } type userService struct { - repo *userRepositoryWrapper + ds model.DataStore + pluginManager PluginUnloader } // NewUser creates a new User service func NewUser(ds model.DataStore, pluginManager PluginUnloader) User { return &userService{ - repo: &userRepositoryWrapper{ - UserRepository: ds.User(), - pluginManager: pluginManager, - }, + ds: ds, + pluginManager: pluginManager, } } -// Repository returns a REST repository wrapper for user operations. +// NewRepository returns a REST repository wrapper for user operations. // The wrapper intercepts Delete operations to coordinate plugin unloading. -func (s *userService) Repository() rest.Repository[model.User] { - return s.repo +func (s *userService) NewRepository(ctx context.Context) rest.Repository { + repo := s.ds.User(ctx) + wrapper := &userRepositoryWrapper{ + ctx: ctx, + UserRepository: repo, + pluginManager: s.pluginManager, + } + return wrapper } type userRepositoryWrapper struct { model.UserRepository + ctx context.Context pluginManager PluginUnloader } -var _ rest.Persistable[model.User] = (*userRepositoryWrapper)(nil) - -// Delete unloads plugins even on error: a bulk delete can fail after earlier users were removed -// and their plugins auto-disabled. -func (r *userRepositoryWrapper) Delete(ctx context.Context, ids ...string) error { - err := r.UserRepository.Delete(ctx, ids...) - r.pluginManager.UnloadDisabledPlugins(ctx) - return err +// Save implements rest.Persistable by delegating to the underlying repository. +func (r *userRepositoryWrapper) Save(entity any) (string, error) { + return r.UserRepository.(rest.Persistable).Save(entity) +} + +// Update implements rest.Persistable by delegating to the underlying repository. +func (r *userRepositoryWrapper) Update(id string, entity any, cols ...string) error { + return r.UserRepository.(rest.Persistable).Update(id, entity, cols...) +} + +// Delete implements rest.Persistable and coordinates plugin unloading. +func (r *userRepositoryWrapper) Delete(id string) error { + // The underlying repository Delete handles the database cleanup + // including calling cleanupPluginUserReferences + err := r.UserRepository.(rest.Persistable).Delete(id) + if err != nil { + return err + } + + // After successful deletion, check if any plugins were auto-disabled + // and need to be unloaded from memory + r.pluginManager.UnloadDisabledPlugins(r.ctx) + + return nil } diff --git a/core/user_test.go b/core/user_test.go index 880637770..b2d3117f8 100644 --- a/core/user_test.go +++ b/core/user_test.go @@ -29,19 +29,19 @@ var _ = Describe("User Service", func() { }) Describe("NewRepository", func() { - It("returns a rest.Persistable[model.User]", func() { - repo := service.Repository() - _, ok := repo.(rest.Persistable[model.User]) + It("returns a rest.Persistable", func() { + repo := service.NewRepository(ctx) + _, ok := repo.(rest.Persistable) Expect(ok).To(BeTrue()) }) }) Describe("Delete", func() { - var repo rest.Persistable[model.User] + var repo rest.Persistable BeforeEach(func() { - r := service.Repository() - repo = r.(rest.Persistable[model.User]) + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) // Add a test user user := &model.User{ @@ -50,45 +50,37 @@ var _ = Describe("User Service", func() { IsAdmin: false, } user.NewPassword = "password" - Expect(userRepo.Put(ctx, user)).To(Succeed()) + Expect(userRepo.Put(user)).To(Succeed()) }) It("deletes the user successfully", func() { - err := repo.Delete(ctx, "user-123") + err := repo.Delete("user-123") Expect(err).NotTo(HaveOccurred()) // Verify user is deleted - _, err = userRepo.Get(ctx, "user-123") + _, err = userRepo.Get("user-123") Expect(err).To(Equal(model.ErrNotFound)) }) It("calls UnloadDisabledPlugins after successful deletion", func() { - err := repo.Delete(ctx, "user-123") + err := repo.Delete("user-123") Expect(err).NotTo(HaveOccurred()) Expect(pluginManager.unloadCalls).To(Equal(1)) }) - It("still calls UnloadDisabledPlugins when deletion fails", func() { - err := repo.Delete(ctx, "non-existent") - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(pluginManager.unloadCalls).To(Equal(1)) - }) - - It("unloads plugins when a bulk delete fails after removing earlier users", func() { - err := repo.Delete(ctx, "user-123", "non-existent") - Expect(err).To(MatchError(model.ErrNotFound)) - - _, err = userRepo.Get(ctx, "user-123") - Expect(err).To(Equal(model.ErrNotFound)) - Expect(pluginManager.unloadCalls).To(Equal(1)) + It("does not call UnloadDisabledPlugins when deletion fails", func() { + // Try to delete non-existent user + err := repo.Delete("non-existent") + Expect(err).To(HaveOccurred()) + Expect(pluginManager.unloadCalls).To(Equal(0)) }) It("returns error when repository fails", func() { userRepo.Error = errors.New("database error") - err := repo.Delete(ctx, "user-123") + err := repo.Delete("user-123") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("database error")) - Expect(pluginManager.unloadCalls).To(Equal(1)) + Expect(pluginManager.unloadCalls).To(Equal(0)) }) }) }) diff --git a/core/wire_providers.go b/core/wire_providers.go index b3df9b2dc..a2fffa34f 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -10,7 +10,6 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/stream" ) @@ -25,6 +24,8 @@ var Set = wire.NewSet( NewLibrary, NewUser, NewMaintenance, + NewImageUploadService, + wire.Bind(new(playlists.ImageUploadService), new(ImageUploadService)), stream.NewTranscodeDecider, agents.GetAgents, external.NewProvider, @@ -33,7 +34,6 @@ var Set = wire.NewSet( ffmpeg.New, scrobbler.GetPlayTracker, playback.GetInstance, - quickconnect.GetInstance, metrics.GetInstance, lyrics.NewLyrics, ) diff --git a/db/backup.go b/db/backup.go index 74cae553e..a34255d7e 100644 --- a/db/backup.go +++ b/db/backup.go @@ -9,7 +9,6 @@ import ( "path/filepath" "regexp" "slices" - "strings" "time" "github.com/mattn/go-sqlite3" @@ -19,7 +18,7 @@ import ( const ( backupPrefix = "navidrome_backup" - backupRegexString = "^" + backupPrefix + "_(.+)\\.db$" + backupRegexString = backupPrefix + "_(.+)\\.db" ) var backupRegex = regexp.MustCompile(backupRegexString) @@ -28,7 +27,7 @@ const backupSuffixLayout = "2006.01.02_15.04.05" func backupPath(t time.Time) string { return filepath.Join( - conf.Server.Backup.Path.MustPath(), + conf.Server.Backup.Path, fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)), ) } @@ -41,18 +40,6 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error { } defer existingConn.Close() - // The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the - // path would create an empty database and "restore" it over the live one. - if !isBackup { - // The driver splits the DSN at '?', so such a path would open a different file. - if strings.ContainsRune(path, '?') { - return fmt.Errorf("backup path cannot contain '?': %s", path) - } - if _, err := os.Stat(path); err != nil { - return fmt.Errorf("backup file not available: %w", err) - } - } - backupDb, err := sql.Open(Driver, path) if err != nil { return fmt.Errorf("opening backup database in '%s': %w", path, err) @@ -130,11 +117,7 @@ func Restore(ctx context.Context, path string) error { } func Prune(ctx context.Context) (int, error) { - 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) + files, err := os.ReadDir(conf.Server.Backup.Path) 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 609cba3d6..aec43446d 100644 --- a/db/backup_test.go +++ b/db/backup_test.go @@ -5,14 +5,12 @@ import ( "database/sql" "math/rand" "os" - "path/filepath" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" . "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/singleton" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -62,7 +60,7 @@ var _ = Describe("database backups", func() { tempFolder, err := os.MkdirTemp("", "navidrome_backup") Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = conf.NewDir(tempFolder) + conf.Server.Backup.Path = tempFolder DeferCleanup(func() { _ = os.RemoveAll(tempFolder) @@ -70,7 +68,6 @@ var _ = Describe("database backups", func() { timesShuffled = make([]time.Time, len(timesDecreasingChronologically)) copy(timesShuffled, timesDecreasingChronologically) - //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(timesShuffled), func(i, j int) { timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i] }) @@ -105,19 +102,6 @@ var _ = Describe("database backups", func() { Entry("delete all files", 0, 0), Entry("preserve all files when at length", len(timesDecreasingChronologically), len(timesDecreasingChronologically)), Entry("preserve all files when less than count", 10000, len(timesDecreasingChronologically))) - - It("ignores SQLite sidecar files when counting backups", func() { - for _, suffix := range []string{"-shm", "-wal"} { - file, err := os.Create(BackupPath(timesDecreasingChronologically[0]) + suffix) - Expect(err).ToNot(HaveOccurred()) - _ = file.Close() - } - - conf.Server.Backup.Count = len(timesDecreasingChronologically) - pruneCount, err := Prune(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(pruneCount).To(BeZero()) - }) }) Describe("backup and restore", Ordered, func() { @@ -134,7 +118,7 @@ var _ = Describe("database backups", func() { BeforeEach(func() { tempFolder, err := os.MkdirTemp("", "navidrome_backup") Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = conf.NewDir(tempFolder) + conf.Server.Backup.Path = tempFolder DeferCleanup(func() { _ = os.RemoveAll(tempFolder) @@ -163,78 +147,4 @@ var _ = Describe("database backups", func() { Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse()) }) }) - - Describe("backup and restore with a file-based database", Ordered, func() { - var ctx context.Context - var tempFolder string - var dbFilePath string - - BeforeAll(func() { - ctx = context.Background() - DeferCleanup(configtest.SetupConfig()) - - var err error - tempFolder, err = os.MkdirTemp("", "navidrome_restore") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - Close(ctx) - _ = os.RemoveAll(tempFolder) - }) - - // Mimic the production DSN (consts.DefaultDbPath): a file database in WAL mode. - dbFilePath = filepath.Join(tempFolder, "navidrome.db") - conf.Server.DbPath = dbFilePath + "?_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal" - // The previous container's cleanup closed the shared *sql.DB without - // dropping the singleton; force a fresh connection for this container. - singleton.DeleteInstance[*sql.DB]() - DeferCleanup(Init(ctx)) - }) - - It("restores data into a database whose stale WAL sidecar files were left behind", func() { - By("seeding user data in the current database") - _, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at) - VALUES ('u-restore-1', 'drilladmin', 'drilladmin', 'drilladmin@example.com', 'x', 1, datetime('now'), datetime('now'))`) - Expect(err).ToNot(HaveOccurred()) - - By("creating a backup containing the user row") - path, err := Backup(ctx) - Expect(err).ToNot(HaveOccurred()) - - By("simulating the CLI exiting without closing the pool: sidecar files stay behind") - _, err = Db().ExecContext(ctx, "CREATE TABLE IF NOT EXISTS _restore_probe(x)") - Expect(err).ToNot(HaveOccurred()) - singleton.DeleteInstance[*sql.DB]() - - err = tests.ClearDB() - Expect(err).ToNot(HaveOccurred()) - - By("restoring the backup") - Expect(Restore(ctx, path)).To(Succeed()) - - By("verifying the restored data is readable through a fresh connection") - singleton.DeleteInstance[*sql.DB]() - var userName string - Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-1'").Scan(&userName)).To(Succeed()) - Expect(userName).To(Equal("drilladmin")) - }) - - It("fails to restore from a backup file that does not exist, leaving the database intact", func() { - By("seeding user data in the current database") - _, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at) - VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`) - Expect(err).ToNot(HaveOccurred()) - - By("attempting a restore from a nonexistent file") - missingPath := filepath.Join(tempFolder, "does_not_exist.db") - err = Restore(ctx, missingPath) - Expect(err).To(HaveOccurred()) - - By("verifying the database was not wiped") - var userName string - Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed()) - Expect(userName).To(Equal("keepme")) - _, statErr := os.Stat(missingPath) - Expect(statErr).To(MatchError(os.ErrNotExist)) - }) - }) }) diff --git a/db/db.go b/db/db.go index 66e48cede..0945d1a00 100644 --- a/db/db.go +++ b/db/db.go @@ -4,25 +4,18 @@ import ( "context" "database/sql" "embed" - "errors" "fmt" - "sync" - "time" + "runtime" "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" _ "github.com/navidrome/navidrome/db/migrations" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/hasher" - "github.com/navidrome/navidrome/utils/natural" "github.com/navidrome/navidrome/utils/singleton" "github.com/pressly/goose/v3" ) -// NaturalCollation sorts embedded numbers by value. It is registered on every -// connection, but only referenced when conf.Server.EnableNaturalSorting is on. -const NaturalCollation = "NATSORT" - var ( Dialect = "sqlite3" Driver = Dialect + "_custom" @@ -34,35 +27,31 @@ var embedMigrations embed.FS const migrationsFolder = "migrations" -// sql.Register panics if called twice, so guard it: the singleton instance can be reset -// (tests/benchmarks) and rebuilt, but the driver is process-global and registers only once. -var registerDriverOnce sync.Once - func Db() *sql.DB { return singleton.GetInstance(func() *sql.DB { - registerDriverOnce.Do(func() { - sql.Register(Driver, &sqlite3.SQLiteDriver{ - ConnectHook: func(conn *sqlite3.SQLiteConn) error { - if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil { - return err - } - return conn.RegisterCollation(NaturalCollation, natural.CompareFold) - }, - }) + sql.Register(Driver, &sqlite3.SQLiteDriver{ + ConnectHook: func(conn *sqlite3.SQLiteConn) error { + return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false) + }, }) Path = conf.Server.DbPath 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) - db.SetMaxOpenConns(conf.MaxOpenConns()) + db.SetMaxOpenConns(max(4, runtime.NumCPU())) 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 }) } @@ -71,6 +60,9 @@ 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 { @@ -109,11 +101,11 @@ func Init(ctx context.Context) func() { log.Fatal(ctx, "Failed to apply new migrations", err) } - if hasSchemaChanges { - log.Debug(ctx, "Running ANALYZE after schema changes") - err = optimizeAt(ctx, db, time.Now()) + if hasSchemaChanges && conf.Server.DevOptimizeDB { + log.Debug(ctx, "Applying PRAGMA optimize after schema changes") + _, err = db.ExecContext(ctx, "PRAGMA optimize") if err != nil { - log.Error(ctx, "Error running ANALYZE", err) + log.Error(ctx, "Error applying PRAGMA optimize", err) } } @@ -122,21 +114,35 @@ func Init(ctx context.Context) func() { } } -// ErrorCodes reports the SQLite result code and extended result code carried by err. -// The extended code is what distinguishes errors that share a message: "database is locked" -// is both SQLITE_BUSY, which busy_timeout retries, and SQLITE_BUSY_SNAPSHOT, which it never can. -func ErrorCodes(err error) (code, extended int, ok bool) { - var se sqlite3.Error - if !errors.As(err, &se) { - return 0, 0, false +// 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 int(se.Code), int(se.ExtendedCode), true -} -// IsBusy reports whether err is SQLITE_BUSY, including BUSY_SNAPSHOT, which only a new transaction clears. -func IsBusy(err error) bool { - code, _, ok := ErrorCodes(err) - return ok && code == int(sqlite3.ErrBusy) + // Return all connections to the Connection Pool + for _, conn := range conns { + conn.Close() + } } type statusLogger struct{ numPending int } @@ -163,28 +169,17 @@ func hasPendingMigrations(ctx context.Context, db *sql.DB, folder string) bool { return l.numPending > 0 } -// hasGooseTable reports whether goose's bookkeeping table exists, i.e. whether the -// database has ever been migrated. -func hasGooseTable(ctx context.Context, db *sql.DB) (bool, error) { - var name string - err := db.QueryRowContext(ctx, - "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version'").Scan(&name) - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return err == nil, err -} - func isSchemaEmpty(ctx context.Context, db *sql.DB) bool { - found, err := hasGooseTable(ctx, db) + rows, err := db.QueryContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck if err != nil { log.Fatal(ctx, "Database could not be opened!", err) } - return !found + defer rows.Close() + return !rows.Next() } type logAdapter struct { - ctx context.Context //nolint:containedctx // goose logger interface has no ctx + ctx context.Context silent bool } diff --git a/db/db_test.go b/db/db_test.go index e3a52e1fb..2ce01dc3d 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -3,12 +3,8 @@ package db_test import ( "context" "database/sql" - "errors" - "fmt" "testing" - "github.com/mattn/go-sqlite3" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/tests" @@ -23,18 +19,6 @@ func TestDB(t *testing.T) { RunSpecs(t, "DB Suite") } -var _ = DescribeTable("IsBusy", - func(err error, expected bool) { - Expect(db.IsBusy(err)).To(Equal(expected)) - }, - Entry("SQLITE_BUSY", sqlite3.Error{Code: sqlite3.ErrBusy}, true), - Entry("SQLITE_BUSY_SNAPSHOT", sqlite3.Error{Code: sqlite3.ErrBusy, ExtendedCode: sqlite3.ErrBusySnapshot}, true), - Entry("a wrapped SQLITE_BUSY", fmt.Errorf("persisting: %w", sqlite3.Error{Code: sqlite3.ErrBusy}), true), - Entry("another SQLite error", sqlite3.Error{Code: sqlite3.ErrConstraint}, false), - Entry("a non-SQLite error", errors.New("database is locked"), false), - Entry("nil", nil, false), -) - var _ = Describe("IsSchemaEmpty", func() { var database *sql.DB var ctx context.Context diff --git a/db/export_test.go b/db/export_test.go index d2034bcdb..734a4462f 100644 --- a/db/export_test.go +++ b/db/export_test.go @@ -2,13 +2,6 @@ package db // Definitions for testing private methods var ( - EmbedMigrations = embedMigrations - FTSTables = ftsTables - FTSTriggerSuffixes = ftsTriggerSuffixes - FTSSearchMigration = ftsSearchMigration - IsSchemaEmpty = isSchemaEmpty - BackupPath = backupPath - OptimizeDBAt = optimizeAt - OptimizeDBIfNeeded = optimizeIfNeeded - RecordAnalyzeFailure = recordAnalyzeFailure + IsSchemaEmpty = isSchemaEmpty + BackupPath = backupPath ) diff --git a/db/migrations/20200130083147_create_schema.go b/db/migrations/20200130083147_create_schema.go index 250fb00a5..2fae4f57d 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(ctx context.Context, tx *sql.Tx) error { +func Up20200130083147(_ context.Context, tx *sql.Tx) error { log.Info("Creating DB Schema") - _, err := tx.ExecContext(ctx, ` + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func Down20200130083147(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200131183653_standardize_item_type.go b/db/migrations/20200131183653_standardize_item_type.go index bf7d9d5f7..471dc8002 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200131183653(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Down20200131183653(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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 6807c8ad2..d058b02c3 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200208222418(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func Down20200208222418(_ context.Context, tx *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 ea5465ade..72b785ef8 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(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, ` +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(` 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, _ *sql.Tx) error { +func Down20200220143731(_ context.Context, tx *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 73436c890..373e0a475 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(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 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 Down20200310171621(_ context.Context, _ *sql.Tx) error { +func Down20200310171621(_ context.Context, tx *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 ef872c4ae..3be91ac35 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200310181627(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` create table transcoding ( id varchar(255) not null primary key, @@ -45,8 +45,8 @@ create table player return err } -func Down20200310181627(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Down20200310181627(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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 a7a6ff0f9..f888cdd4c 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200319211049(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx, "A full rescan will be performed!") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) } -func Down20200319211049(_ context.Context, _ *sql.Tx) error { +func Down20200319211049(_ context.Context, tx *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 01537f886..f01f2c558 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200325185135(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx, "A full rescan will be performed!") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) } -func Down20200325185135(_ context.Context, _ *sql.Tx) error { +func Down20200325185135(_ context.Context, tx *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 17afe37fe..c42e8c327 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(ctx context.Context, tx *sql.Tx) error { - notice(ctx, tx, "A full rescan will be performed!") - return forceFullRescan(ctx, tx) +func Up20200326090707(_ context.Context, tx *sql.Tx) error { + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) } -func Down20200326090707(_ context.Context, _ *sql.Tx) error { +func Down20200326090707(_ context.Context, tx *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 d9b048e22..66f2b23e8 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200327193744(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx, "A full rescan will be performed!") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) } -func Down20200327193744(_ context.Context, _ *sql.Tx) error { +func Down20200327193744(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200404214704_add_indexes.go b/db/migrations/20200404214704_add_indexes.go index 8b8d8607e..6207b0a3d 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200404214704(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func Down20200404214704(_ context.Context, tx *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 482341a89..22006c8af 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(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 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 Down20200409002249(_ context.Context, _ *sql.Tx) error { +func Down20200409002249(_ context.Context, tx *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 4aa502b4b..266dc087d 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200411164603(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` alter table playlist add created_at datetime; alter table playlist @@ -23,6 +23,6 @@ update playlist return err } -func Down20200411164603(_ context.Context, _ *sql.Tx) error { +func Down20200411164603(_ context.Context, tx *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 54e03f4c6..22b024cea 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(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 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 Down20200418110522(_ context.Context, _ *sql.Tx) error { +func Down20200418110522(_ context.Context, tx *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 89e3ccee5..efeb1bb84 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(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 Up20200419222708(_ context.Context, tx *sql.Tx) error { + notice(tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(tx) } -func Down20200419222708(_ context.Context, _ *sql.Tx) error { +func Down20200419222708(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200423204116_add_sort_fields.go b/db/migrations/20200423204116_add_sort_fields.go index a51bb2270..4097a9d60 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200423204116(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx, "A full rescan will be performed to change the search behaviour") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(tx) } -func Down20200423204116(_ context.Context, _ *sql.Tx) error { +func Down20200423204116(_ context.Context, tx *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 72a47bc94..aac78e698 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200508093059(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` alter table artist add song_count integer default 0 not null; `) if err != nil { return err } - notice(ctx, tx, "A full rescan will be performed to calculate artists' song counts") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan will be performed to calculate artists' song counts") + return forceFullRescan(tx) } -func Down20200508093059(_ context.Context, _ *sql.Tx) error { +func Down20200508093059(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200512104202_add_disc_subtitle.go b/db/migrations/20200512104202_add_disc_subtitle.go index 29734e0c0..b3e907d8d 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200512104202(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` alter table media_file add disc_subtitle varchar(255); `) if err != nil { return err } - notice(ctx, tx, "A full rescan will be performed to import disc subtitles") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan will be performed to import disc subtitles") + return forceFullRescan(tx) } -func Down20200512104202(_ context.Context, _ *sql.Tx) error { +func Down20200512104202(_ context.Context, tx *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 59265e410..fcaae9d8e 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200516140647(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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.QueryContext(ctx, "select id, tracks from playlist") + rows, err := tx.Query("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.ExecContext(ctx, ` + _, err = tx.Exec(` 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, _ *sql.Tx) error { +func Down20200516140647(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200608153717_referential_integrity.go b/db/migrations/20200608153717_referential_integrity.go index c9c766f7e..2959237fa 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(ctx context.Context, tx *sql.Tx) error { +func Up20200608153717(_ context.Context, tx *sql.Tx) error { // First delete dangling players - _, err := tx.ExecContext(ctx, ` + _, err := tx.Exec(` delete from player where user_name not in (select user_name from user)`) if err != nil { return err } // Also delete dangling players - _, err = tx.ExecContext(ctx, ` + _, err = tx.Exec(` delete from playlist where owner not in (select user_name from user)`) if err != nil { return err } // Also delete dangling playlist tracks - _, err = tx.ExecContext(ctx, ` + _, err = tx.Exec(` 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(ctx, tx) + err = updatePlayer_20200608153717(tx) if err != nil { return err } // Add foreign key to playlist table - err = updatePlaylist_20200608153717(ctx, tx) + err = updatePlaylist_20200608153717(tx) if err != nil { return err } // Add foreign keys to playlist_tracks table - return updatePlaylistTracks_20200608153717(ctx, tx) + return updatePlaylistTracks_20200608153717(tx) } -func updatePlayer_20200608153717(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func updatePlayer_20200608153717(tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func updatePlaylist_20200608153717(tx *sql.Tx) error { + _, err := tx.Exec(` create table playlist_dg_tmp ( id varchar(255) not null @@ -108,8 +108,8 @@ create index playlist_name return err } -func updatePlaylistTracks_20200608153717(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func updatePlaylistTracks_20200608153717(tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func Down20200608153717(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200706231659_add_default_transcodings.go b/db/migrations/20200706231659_add_default_transcodings.go index e87481ae1..a498d32b0 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(ctx context.Context, tx *sql.Tx) error { - row := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding") +func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { + row := tx.QueryRow("SELECT COUNT(*) FROM transcoding") var count int err := row.Scan(&count) if err != nil { @@ -38,6 +38,6 @@ func upAddDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { return nil } -func downAddDefaultTranscodings(_ context.Context, _ *sql.Tx) error { +func downAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200710211442_add_playlist_path.go b/db/migrations/20200710211442_add_playlist_path.go index 32cc8d034..8abfed6cf 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddPlaylistPath(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` alter table playlist add path string default '' not null; @@ -23,6 +23,6 @@ alter table playlist return err } -func downAddPlaylistPath(_ context.Context, _ *sql.Tx) error { +func downAddPlaylistPath(_ context.Context, tx *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 7a27137bc..d63a1ecb9 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` create table playqueue ( id varchar(255) not null primary key, @@ -32,6 +32,6 @@ create table playqueue return err } -func downCreatePlayQueuesTable(_ context.Context, _ *sql.Tx) error { +func downCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200801101355_create_bookmark_table.go b/db/migrations/20200801101355_create_bookmark_table.go index df814d7b8..fe68fafd7 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upCreateBookmarkTable(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downCreateBookmarkTable(_ context.Context, tx *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 8259ad3fe..b2dd4285c 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downDropEmailUniqueConstraint(_ context.Context, tx *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 b46430743..7ee7a283f 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201003111749(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` create index if not exists annotation_starred_at on annotation (starred_at); `) return err } -func Down20201003111749(_ context.Context, _ *sql.Tx) error { +func Down20201003111749(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20201010162350_add_album_size.go b/db/migrations/20201010162350_add_album_size.go index df1fa8ca2..f1182ab6c 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201010162350(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx context.Context, tx *sql.Tx) error { +func Down20201010162350(_ 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 1c738dd1e..4eb67f14e 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201012210022(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func Down20201012210022(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20201021085410_add_mbids.go b/db/migrations/20201021085410_add_mbids.go index 53001fc73..624bb1a67 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201021085410(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) } -func Down20201021085410(ctx context.Context, tx *sql.Tx) error { +func Down20201021085410(_ 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 7d6ad4965..f3a800949 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201021093209(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func Down20201021093209(_ context.Context, tx *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 e8f22c3a7..ca04d8a20 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201021135455(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` create index if not exists media_file_artist_id on media_file (artist_id); `) return err } -func Down20201021135455(_ context.Context, _ *sql.Tx) error { +func Down20201021135455(_ context.Context, tx *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 e33e15c23..f2917ae49 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddArtistImageUrl(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downAddArtistImageUrl(_ context.Context, tx *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 c60917bdd..5bb17b8d0 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201110205344(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` alter table media_file add comment varchar; alter table media_file @@ -24,10 +24,10 @@ alter table album if err != nil { return err } - notice(ctx, tx, "A full rescan will be performed to import comments and lyrics") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan will be performed to import comments and lyrics") + return forceFullRescan(tx) } -func Down20201110205344(_ context.Context, _ *sql.Tx) error { +func Down20201110205344(_ context.Context, tx *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 4b3f62128..db102dfa9 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201128100726(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` alter table player add report_real_path bool default FALSE not null; `) return err } -func Down20201128100726(_ context.Context, _ *sql.Tx) error { +func Down20201128100726(_ context.Context, tx *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 81c30d611..170497f5c 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201213124814(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx) + return updateAlbums20201213124814(tx) } -func updateAlbums20201213124814(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.QueryContext(ctx, ` +func updateAlbums20201213124814(tx *sql.Tx) error { + rows, err := tx.Query(` 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, _ *sql.Tx) error { +func Down20201213124814(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210322132848_add_timestamp_indexes.go b/db/migrations/20210322132848_add_timestamp_indexes.go index 5ed250fea..3341dd3d2 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210418232815_fix_album_comments.go b/db/migrations/20210418232815_fix_album_comments.go index 3c7ed86c1..59067640a 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(ctx context.Context, tx *sql.Tx) error { +func upFixAlbumComments(_ context.Context, tx *sql.Tx) error { //nolint:gosec - 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; + 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; `) if err != nil { return err @@ -49,7 +49,7 @@ func upFixAlbumComments(ctx context.Context, tx *sql.Tx) error { return rows.Err() } -func downFixAlbumComments(_ context.Context, _ *sql.Tx) error { +func downFixAlbumComments(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210430212322_add_bpm_metadata.go b/db/migrations/20210430212322_add_bpm_metadata.go index 00a0f1447..721c9e179 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddBpmMetadata(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) } -func downAddBpmMetadata(_ context.Context, _ *sql.Tx) error { +func downAddBpmMetadata(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210530121921_create_shares_table.go b/db/migrations/20210530121921_create_shares_table.go index d9e902a43..e9208bd69 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upCreateSharesTable(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` create table share ( id varchar(255) not null primary key, @@ -30,6 +30,6 @@ create table share return err } -func downCreateSharesTable(_ context.Context, _ *sql.Tx) error { +func downCreateSharesTable(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210601231734_update_share_fieldnames.go b/db/migrations/20210601231734_update_share_fieldnames.go index 5a459a34c..965c0186e 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210616150710_encrypt_all_passwords.go b/db/migrations/20210616150710_encrypt_all_passwords.go index dc8a9abd4..f67e3fb0a 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.QueryContext(ctx, `SELECT id, user_name, password from user;`) + rows, err := tx.Query(`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, _ *sql.Tx) error { +func downEncodeAllPasswords(_ context.Context, tx *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 734ffc340..200332156 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downDropPlayerNameUniqueConstraint(_ context.Context, tx *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 aa5e7a8f0..5257dfab3 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(ctx context.Context, tx *sql.Tx) error { - err := upAddUserPrefs(ctx, tx) +func upAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error { + err := upAddUserPrefs(tx) if err != nil { return err } - return upPlayerScrobblerEnabled(ctx, tx) + return upPlayerScrobblerEnabled(tx) } -func upAddUserPrefs(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddUserPrefs(tx *sql.Tx) error { + _, err := tx.Exec(` create table user_props ( user_id varchar not null, @@ -33,13 +33,13 @@ create table user_props return err } -func upPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upPlayerScrobblerEnabled(tx *sql.Tx) error { + _, err := tx.Exec(` alter table player add scrobble_enabled bool default true; `) return err } -func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, _ *sql.Tx) error { +func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *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 b2f93b4e3..033392d93 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210626213026_add_scrobble_buffer.go b/db/migrations/20210626213026_add_scrobble_buffer.go index 75d9d681c..1c4d0de2a 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210715151153_add_genre_tables.go b/db/migrations/20210715151153_add_genre_tables.go index 143f9c72b..ab2c54239 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(ctx context.Context, tx *sql.Tx) error { - notice(ctx, tx, "A full rescan will be performed to import multiple genres!") - _, err := tx.ExecContext(ctx, ` +func upAddGenreTables(_ context.Context, tx *sql.Tx) error { + notice(tx, "A full rescan will be performed to import multiple genres!") + _, err := tx.Exec(` 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(ctx, tx) + return forceFullRescan(tx) } -func downAddGenreTables(_ context.Context, _ *sql.Tx) error { +func downAddGenreTables(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210821212604_add_mediafile_channels.go b/db/migrations/20210821212604_add_mediafile_channels.go index ee18be01b..9a0988b17 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddMediafileChannels(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) } -func downAddMediafileChannels(_ context.Context, _ *sql.Tx) error { +func downAddMediafileChannels(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20211008205505_add_smart_playlist.go b/db/migrations/20211008205505_add_smart_playlist.go index 0d2d1ad4e..c8ed67c47 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddSmartPlaylist(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downAddSmartPlaylist(_ context.Context, tx *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 4a2ae4047..ee6fc67d1 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx) + return upAddOrderTitleToMediaFile_populateOrderTitle(tx) } //goland:noinspection GoSnakeCaseUsage -func upAddOrderTitleToMediaFile_populateOrderTitle(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.QueryContext(ctx, `select id, title from media_file`) +func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error { + rows, err := tx.Query(`select id, title from media_file`) if err != nil { return err } @@ -57,6 +57,6 @@ func upAddOrderTitleToMediaFile_populateOrderTitle(ctx context.Context, tx *sql. return rows.Err() } -func downAddOrderTitleToMediaFile(_ context.Context, _ *sql.Tx) error { +func downAddOrderTitleToMediaFile(_ context.Context, tx *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 a7969ffed..d4ba5e194 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(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.QueryContext(ctx, `select id, comment, lyrics, title from media_file`) +func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { + rows, err := tx.Query(`select id, comment, lyrics, title from media_file`) if err != nil { return err } @@ -43,6 +43,6 @@ func upUnescapeLyricsAndComments(ctx context.Context, tx *sql.Tx) error { return rows.Err() } -func downUnescapeLyricsAndComments(_ context.Context, _ *sql.Tx) error { +func downUnescapeLyricsAndComments(_ context.Context, tx *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 909ea1c54..e262fc205 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downAddUseridToPlaylist(_ context.Context, tx *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 f786b69e7..4ab4305d0 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` create index album_alphabetical_by_artist ON album(compilation, order_album_artist_name, order_album_name) `) return err } -func downAddAlphabeticalByArtistIndex(_ context.Context, _ *sql.Tx) error { +func downAddAlphabeticalByArtistIndex(_ context.Context, tx *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 8c9887dd1..5e078c820 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id) `) return err } -func downRemoveInvalidArtistIds(_ context.Context, _ *sql.Tx) error { +func downRemoveInvalidArtistIds(_ context.Context, tx *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 42e13a1e5..481762117 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` alter table media_file add mbz_release_track_id varchar(255); `) if err != nil { return err } - notice(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) } -func downAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error { +func downAddMusicbrainzReleaseTrackId(_ 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 f8ebd40e9..ee9c77c8a 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` alter table main.album add image_files varchar; `) if err != nil { return err } - notice(ctx, tx, "A full rescan needs to be performed to import all album images") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan needs to be performed to import all album images") + return forceFullRescan(tx) } -func downAddAlbumImagePaths(_ context.Context, _ *sql.Tx) error { +func downAddAlbumImagePaths(_ context.Context, tx *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 30f86297a..a1eaa89f9 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upRemoveCoverArtId(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx, "A full rescan needs to be performed to import all album images") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan needs to be performed to import all album images") + return forceFullRescan(tx) } -func downRemoveCoverArtId(_ context.Context, _ *sql.Tx) error { +func downRemoveCoverArtId(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20230112111457_add_album_paths.go b/db/migrations/20230112111457_add_album_paths.go index 2819522a1..2dfb9a747 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `alter table album add paths varchar;`) +func upAddAlbumPaths(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(`alter table album add paths varchar;`) if err != nil { return err } //nolint:gosec - rows, err := tx.QueryContext(ctx, ` - select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id + rows, err := tx.Query(` + 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, _ *sql.Tx) error { +func downAddAlbumPaths(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20230114121537_touch_playlists.go b/db/migrations/20230114121537_touch_playlists.go index 71959b0a8..0f10e275c 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `update playlist set updated_at = datetime('now');`) +func upTouchPlaylists(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(`update playlist set updated_at = datetime('now');`) return err } -func downTouchPlaylists(_ context.Context, _ *sql.Tx) error { +func downTouchPlaylists(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20230115103212_create_internet_radio.go b/db/migrations/20230115103212_create_internet_radio.go index 3e0da348f..5c014dac2 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upCreateInternetRadio(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downCreateInternetRadio(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20230117155559_add_replaygain_metadata.go b/db/migrations/20230117155559_add_replaygain_metadata.go index 3aad70925..d6be3b313 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) } -func downAddReplaygainMetadata(_ context.Context, _ *sql.Tx) error { +func downAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20230117180400_add_album_info.go b/db/migrations/20230117180400_add_album_info.go index 750d3838f..5d6dd8230 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddAlbumInfo(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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, _ *sql.Tx) error { +func downAddAlbumInfo(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20230119152657_recreate_share_table.go b/db/migrations/20230119152657_recreate_share_table.go index 10eff31ca..e1ae816c0 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddMissingShareInfo(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` drop table if exists share; create table share ( @@ -37,6 +37,6 @@ create table share return err } -func downAddMissingShareInfo(_ context.Context, _ *sql.Tx) error { +func downAddMissingShareInfo(_ context.Context, tx *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 fb5f2be1a..78b030ae4 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(ctx context.Context, tx *sql.Tx) error { +func upChangePathListSeparator(_ context.Context, tx *sql.Tx) error { //nolint:gosec - rows, err := tx.QueryContext(ctx, ` - select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id + rows, err := tx.Query(` + 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, _ *sql.Tx) error { +func downChangePathListSeparator(_ context.Context, tx *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 e5dc4ab43..7f4d4cb0e 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(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.QueryContext(ctx, `select id, image_files from album`) +func upChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error { + rows, err := tx.Query(`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(ctx context.Context, tx *sql.Tx) error { +func downChangeImageFilesListSeparator(_ 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 3ee24cc77..ed2879ec3 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddDownloadToShare(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` alter table share add downloadable bool not null default false; `) return err } -func downAddDownloadToShare(_ context.Context, _ *sql.Tx) error { +func downAddDownloadToShare(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20230515184510_add_release_date.go b/db/migrations/20230515184510_add_release_date.go index f22bdfae8..1141a1e74 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddRelRecYear(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) } -func downAddRelRecYear(_ context.Context, _ *sql.Tx) error { +func downAddRelRecYear(_ context.Context, tx *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 562a59bb5..170fc264c 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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` alter table media_file rename column mbz_track_id to mbz_recording_id; `) return err } -func downRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func downRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` 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 7f1ad2f38..ac73fc98f 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.QueryContext(ctx, `select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`) + rows, err := tx.Query(`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 } - parsed, err := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(lyrics.String)) + lyrics, err := model.ToLyrics("xxx", lyrics.String) if err != nil { return err } - text, err := json.Marshal(parsed) + text, err := json.Marshal(model.LyricList{*lyrics}) if err != nil { return err } @@ -72,7 +72,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { return err } - notice(ctx, tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)") + notice(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 518d125e4..a65b0aefd 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 76b809c36..619cdcffd 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(ctx, tx, "A full rescan should be performed to pick up additional tags") + notice(tx, "A full rescan should be performed to pick up additional tags") return err } diff --git a/db/migrations/20240511220020_add_library_table.go b/db/migrations/20240511220020_add_library_table.go index d7b4aa5c2..55b521ca9 100644 --- a/db/migrations/20240511220020_add_library_table.go +++ b/db/migrations/20240511220020_add_library_table.go @@ -3,6 +3,7 @@ package migrations import ( "context" "database/sql" + "fmt" "github.com/navidrome/navidrome/conf" "github.com/pressly/goose/v3" @@ -27,10 +28,10 @@ func upAddLibraryTable(ctx context.Context, tx *sql.Tx) error { return err } - _, err = tx.ExecContext(ctx, ` - insert into library(id, name, path) values(1, 'Music Library', ?); - delete from property where id like 'LastScan-%'; -`, conf.Server.MusicFolder) + _, err = tx.ExecContext(ctx, fmt.Sprintf(` + insert into library(id, name, path) values(1, 'Music Library', '%s'); + delete from property where id like 'LastScan-%%'; +`, conf.Server.MusicFolder)) if err != nil { return err } diff --git a/db/migrations/20240629152843_remove_annotation_id.go b/db/migrations/20240629152843_remove_annotation_id.go index 972932e10..b450b26d4 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(_ context.Context, _ *sql.Tx) error { +func downRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20241026183640_support_new_scanner.go b/db/migrations/20241026183640_support_new_scanner.go index f5899f08f..fcbef7e4e 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(ctx, tx, "A full scan will be triggered to populate the new tables. This may take a while.") - return forceFullRescan(ctx, tx) + notice(tx, "A full scan will be triggered to populate the new tables. This may take a while.") + return forceFullRescan(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 1b83c0b35..d9250eba2 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(_ context.Context, _ *sql.Tx) error { +func downPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010101_add_folder_hash.go b/db/migrations/20250701010101_add_folder_hash.go index c350d31f5..e82a0749f 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(_ context.Context, _ *sql.Tx) error { +func downAddFolderHash(ctx context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20250701010103_add_library_stats.go b/db/migrations/20250701010103_add_library_stats.go index a84758a04..8025229cc 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(_ context.Context, _ *sql.Tx) error { +func downAddLibraryStats(ctx context.Context, tx *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 c6beb2a51..163608d32 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(ctx, tx, "Fetching replaygain fields properly will require a full scan") + notice(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 6f2bde429..dc4cd647b 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(ctx, tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.") + notice(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 f52f48440..4e8b1b7f5 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(ctx context.Context, tx *sql.Tx) error { +func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { // Add codec column to media_file. - _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) + _, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) if err != nil { return err } - _, err = tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) + _, err = tx.Exec(`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.ExecContext(ctx, + _, err = tx.Exec( `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(ctx context.Context, tx *sql.Tx) error { // Add FLAC transcoding for existing installations that were seeded before FLAC was added. var count int - err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) + err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) if err != nil { return err } if count == 0 { - _, err = tx.ExecContext(ctx, + _, err = tx.Exec( "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(ctx context.Context, tx *sql.Tx) error { } // Add probe_data column for caching ffprobe results. - _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) + _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) if err != nil { return err } return nil } -func downAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) +func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) if err != nil { return err } - _, err = tx.ExecContext(ctx, `DROP INDEX IF EXISTS media_file_codec`) + _, err = tx.Exec(`DROP INDEX IF EXISTS media_file_codec`) if err != nil { return err } - _, err = tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN codec`) + _, err = tx.Exec(`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 c76d6ed1a..a7e7366ed 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(ctx context.Context, tx *sql.Tx) error { +func upFixProbeDataNull(_ 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.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) + _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) if err != nil { return err } - _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) + _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) return err } -func downFixProbeDataNull(_ context.Context, _ *sql.Tx) error { +func downFixProbeDataNull(_ context.Context, tx *sql.Tx) error { return nil } diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go index 4df66d26d..ab6d24952 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(ctx context.Context, tx *sql.Tx) error { +func upEnsureDefaultTranscodings(_ 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(ctx context.Context, tx *sql.Tx) error { // but the same name. for _, t := range consts.DefaultTranscodings { var count int - err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count) + err := tx.QueryRow("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.ExecContext(ctx, + _, err = tx.Exec( "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(ctx context.Context, tx *sql.Tx) error { return nil } -func downEnsureDefaultTranscodings(_ context.Context, _ *sql.Tx) error { +func downEnsureDefaultTranscodings(_ context.Context, tx *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 a4fa8fcc1..588137383 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(ctx context.Context, tx *sql.Tx) error { +func upFixAacTranscodeCommand(_ 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.ExecContext(ctx, + _, err := tx.Exec( "UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?", newCommand, oldCommand, ) return err } -func downFixAacTranscodeCommand(_ context.Context, _ *sql.Tx) error { +func downFixAacTranscodeCommand(_ context.Context, tx *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 deleted file mode 100644 index 472ae7b43..000000000 --- a/db/migrations/20260513173954_move_ss_before_input.go +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index f65050d80..000000000 --- a/db/migrations/20260520211813_add_media_file_artists_composite_index.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +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 deleted file mode 100644 index 8bd8ad5b8..000000000 --- a/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +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 deleted file mode 100644 index c84323158..000000000 --- a/db/migrations/20260612222838_make_bpm_bitdepth_nullable.sql +++ /dev/null @@ -1,35 +0,0 @@ --- +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 deleted file mode 100644 index 2186cda91..000000000 --- a/db/migrations/20260618120509_add_metadata_to_default_transcodings.go +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 731959e62..000000000 --- a/db/migrations/20260629123100_recently_added_plain_indexes.sql +++ /dev/null @@ -1,28 +0,0 @@ --- +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 deleted file mode 100644 index 93902e230..000000000 --- a/db/migrations/20260702152457_backfill_artist_search_normalized.go +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index dd36bf4c2..000000000 --- a/db/migrations/20260703013908_optimize_media_file_sort_indexes.sql +++ /dev/null @@ -1,56 +0,0 @@ --- +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 deleted file mode 100644 index 220d7cf75..000000000 --- a/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql +++ /dev/null @@ -1,39 +0,0 @@ --- +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 deleted file mode 100644 index 5db642986..000000000 --- a/db/migrations/20260714120000_add_playlist_average_rating.sql +++ /dev/null @@ -1,5 +0,0 @@ --- +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/20260714123822_add_media_file_title_sort_covering_index.sql b/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql deleted file mode 100644 index 18666eef9..000000000 --- a/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql +++ /dev/null @@ -1,22 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- Covering index for the title-sorted, library-scoped song listing: --- WHERE missing = ? AND library_id = ? ORDER BY order_title LIMIT n OFFSET m --- (Jellyfin clients page through the whole library this way; non-admin native and --- Subsonic song lists produce the same shape.) --- --- Without it, SQLite walks media_file_order_title and must fetch the table row for --- every *skipped* entry just to evaluate the WHERE, so a deep page costs offset+limit --- random row reads (seconds on cold spinning disks). With the filter columns in the --- index the skip is index-only. `id` is included because the annotation/bookmark --- LEFT JOINs run per candidate row and need the join key; without it each skipped --- entry still triggers a row fetch. -create index if not exists media_file_missing_library_order_title - on media_file(missing, library_id, order_title, id); --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -drop index if exists media_file_missing_library_order_title; --- +goose StatementEnd diff --git a/db/migrations/20260719005427_add_album_replaygain.go b/db/migrations/20260719005427_add_album_replaygain.go deleted file mode 100644 index 7f5665947..000000000 --- a/db/migrations/20260719005427_add_album_replaygain.go +++ /dev/null @@ -1,41 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddAlbumReplaygain, downAddAlbumReplaygain) -} - -func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { - // Backfill the most-frequent value per album (matching MediaFiles.ToAlbum), staging RG-bearing rows - // into an indexed temp table — a correlated subquery over a windowed CTE re-scans media_file per album. - _, err := tx.ExecContext(ctx, ` -ALTER TABLE album ADD COLUMN rg_album_gain real; -ALTER TABLE album ADD COLUMN rg_album_peak real; - -CREATE TEMP TABLE _rg_backfill AS - SELECT album_id, rg_album_gain, rg_album_peak FROM media_file - WHERE rg_album_gain IS NOT NULL OR rg_album_peak IS NOT NULL; -CREATE INDEX _rg_backfill_album ON _rg_backfill(album_id); - -UPDATE album SET - rg_album_gain = (SELECT rg_album_gain FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_gain IS NOT NULL - GROUP BY rg_album_gain ORDER BY count(*) DESC, rg_album_gain LIMIT 1), - rg_album_peak = (SELECT rg_album_peak FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_peak IS NOT NULL - GROUP BY rg_album_peak ORDER BY count(*) DESC, rg_album_peak LIMIT 1) -WHERE album.id IN (SELECT album_id FROM _rg_backfill); - -DROP TABLE _rg_backfill; - `) - return err -} - -func downAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { - // This code is executed when the migration is rolled back. - return nil -} diff --git a/db/migrations/20260720015443_uniform_canonical_ids.go b/db/migrations/20260720015443_uniform_canonical_ids.go deleted file mode 100644 index 346f22050..000000000 --- a/db/migrations/20260720015443_uniform_canonical_ids.go +++ /dev/null @@ -1,310 +0,0 @@ -package migrations - -import ( - "context" - "crypto/md5" - "database/sql" - "encoding/hex" - "encoding/json" - "fmt" - "math/big" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model/id" - "github.com/pressly/goose/v3" -) - -// canonicalID maps any historical Navidrome id shape to the canonical 22-char base62 encoding -// of a 128-bit value; unrecognized shapes (including empty and share ids) pass through unchanged. -func canonicalID(s string) string { - switch len(s) { - case 22: - v, ok := new(big.Int).SetString(s, 62) - if !ok || v.Sign() < 0 || v.BitLen() <= 128 { - return s - } - sum := md5.Sum([]byte(s)) - return id.Encode(sum) - case 32: - b, err := hex.DecodeString(s) - if err != nil { - return s - } - return id.Encode([16]byte(b)) - case 36: - if s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' { - return s - } - b, err := hex.DecodeString(s[:8] + s[9:13] + s[14:18] + s[19:23] + s[24:]) - if err != nil { - return s - } - return id.Encode([16]byte(b)) - } - return s -} - -func init() { - goose.AddMigrationContext(upUniformCanonicalIds, downUniformCanonicalIds) -} - -// idColumns is the exhaustive list of Navidrome-id-bearing columns; anything absent is deliberately exempt. -var idColumns = []struct{ table, col string }{ - {"media_file", "id"}, {"media_file", "pid"}, {"media_file", "artist_id"}, - {"media_file", "album_id"}, {"media_file", "album_artist_id"}, {"media_file", "folder_id"}, - {"album", "id"}, {"album", "album_artist_id"}, - {"artist", "id"}, - {"folder", "id"}, {"folder", "parent_id"}, - {"tag", "id"}, - {"library_artist", "artist_id"}, - {"user", "id"}, - {"user_props", "user_id"}, - {"playlist", "id"}, {"playlist", "owner_id"}, - {"playlist_tracks", "playlist_id"}, {"playlist_tracks", "media_file_id"}, - {"playlist_fields", "playlist_id"}, - {"annotation", "user_id"}, {"annotation", "item_id"}, - {"bookmark", "user_id"}, {"bookmark", "item_id"}, - {"player", "id"}, {"player", "user_id"}, {"player", "transcoding_id"}, - {"transcoding", "id"}, - {"radio", "id"}, - {"share", "user_id"}, - {"scrobble_buffer", "id"}, {"scrobble_buffer", "user_id"}, {"scrobble_buffer", "media_file_id"}, - {"playqueue", "id"}, {"playqueue", "user_id"}, - {"user_library", "user_id"}, - {"scrobbles", "user_id"}, {"scrobbles", "media_file_id"}, - {"media_file_artists", "media_file_id"}, {"media_file_artists", "artist_id"}, - {"album_artists", "album_id"}, {"album_artists", "artist_id"}, - {"library_tag", "tag_id"}, -} - -// embeddedIDColumns holds ids nested inside a larger value; the id-columns guard checks this -// list against the schema, so every JSON column must appear here or be exempted there. -var embeddedIDColumns = []struct { - table, col string - transform func(string) (string, bool) -}{ - {"playqueue", "items", canonicalizeIDList}, - {"share", "resource_ids", canonicalizeIDList}, - {"plugin", "users", canonicalizePluginUsers}, - {"playlist", "rules", canonicalizePlaylistRules}, -} - -func upUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error { - if err := buildIDMap(ctx, tx); err != nil { - return err - } - for _, tc := range idColumns { - if err := applyIDMap(ctx, tx, tc.table, tc.col); err != nil { - return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err) - } - } - for _, tc := range embeddedIDColumns { - if err := rewriteColumn(ctx, tx, tc.table, tc.col, tc.transform); err != nil { - return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err) - } - } - // Legacy PID specs embed old-shaped album/track ids into composite pids; a full rescan - // rewrites every pid with the new encoding so path-based move matching stays consistent. - if strings.Contains(conf.Server.PID.Track, "legacy") || strings.Contains(conf.Server.PID.Album, "legacy") { - if err := forceFullRescan(ctx, tx); err != nil { - return err - } - } - if err := rotateSessionSecret(ctx, tx); err != nil { - return err - } - _, err := tx.ExecContext(ctx, "DROP TABLE _id_map") - return err -} - -// rotateSessionSecret renames the session JWT secret to the public key, so public-link tokens -// keep verifying while auth.Init mints a fresh session secret, killing every stale session. -func rotateSessionSecret(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, - `UPDATE property SET id = ? WHERE id = ? AND NOT EXISTS (SELECT 1 FROM property WHERE id = ?)`, - consts.JWTPublicSecretKey, consts.JWTSecretKey, consts.JWTPublicSecretKey) - return err -} - -// buildIDMap stages old->new pairs for every id that changes, indexed for the update joins. -func buildIDMap(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, - "CREATE TEMP TABLE _id_map (old_id TEXT PRIMARY KEY, new_id TEXT NOT NULL) WITHOUT ROWID") - if err != nil { - return err - } - ins, err := tx.PrepareContext(ctx, "INSERT OR IGNORE INTO _id_map (old_id, new_id) VALUES (?, ?)") - if err != nil { - return err - } - defer ins.Close() - for _, tc := range idColumns { - if err := collectColumn(ctx, tx, ins, tc.table, tc.col); err != nil { - return fmt.Errorf("collecting %s.%s: %w", tc.table, tc.col, err) - } - } - return nil -} - -func collectColumn(ctx context.Context, tx *sql.Tx, ins *sql.Stmt, table, col string) error { - rows, err := tx.QueryContext(ctx, fmt.Sprintf( - "SELECT DISTINCT %[2]s FROM %[1]s WHERE %[2]s IS NOT NULL AND %[2]s <> ''", table, col)) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var old string - if err := rows.Scan(&old); err != nil { - return err - } - if newID := canonicalID(old); newID != old { - if _, err := ins.ExecContext(ctx, old, newID); err != nil { - return err - } - } - } - return rows.Err() -} - -func applyIDMap(ctx context.Context, tx *sql.Tx, table, col string) error { - _, err := tx.ExecContext(ctx, fmt.Sprintf( - `UPDATE %[1]s SET %[2]s = (SELECT new_id FROM _id_map WHERE old_id = %[1]s.%[2]s) - WHERE %[2]s IN (SELECT old_id FROM _id_map)`, table, col)) - return err -} - -// canonicalizeIDList canonicalizes a comma-separated id list element-wise. -func canonicalizeIDList(s string) (string, bool) { - parts := strings.Split(s, ",") - changed := false - for i, p := range parts { - if n := canonicalID(p); n != p { - parts[i] = n - changed = true - } - } - if !changed { - return s, false - } - return strings.Join(parts, ","), true -} - -// rewriteColumn applies transform to each non-empty cell, updating only rows it changed. -func rewriteColumn(ctx context.Context, tx *sql.Tx, table, col string, transform func(string) (string, bool)) error { - rows, err := tx.QueryContext(ctx, fmt.Sprintf( - "SELECT rowid, %[2]s FROM %[1]s WHERE ifnull(%[2]s, '') <> ''", table, col)) - if err != nil { - return err - } - type change struct { - rowid int64 - val string - } - var changes []change - for rows.Next() { - var rowid int64 - var val string - if err := rows.Scan(&rowid, &val); err != nil { - _ = rows.Close() - return err - } - if newVal, ok := transform(val); ok { - changes = append(changes, change{rowid, newVal}) - } - } - if err := rows.Err(); err != nil { - _ = rows.Close() - return err - } - _ = rows.Close() - if len(changes) == 0 { - return nil - } - upd, err := tx.PrepareContext(ctx, fmt.Sprintf("UPDATE %s SET %s = ? WHERE rowid = ?", table, col)) - if err != nil { - return err - } - defer upd.Close() - for _, c := range changes { - if _, err := upd.ExecContext(ctx, c.val, c.rowid); err != nil { - return err - } - } - return nil -} - -// canonicalizePluginUsers maps a JSON array of user ids; malformed JSON passes through untouched. -func canonicalizePluginUsers(s string) (string, bool) { - var users []string - if err := json.Unmarshal([]byte(s), &users); err != nil { - return s, false - } - changed := false - for i, u := range users { - if n := canonicalID(u); n != u { - users[i] = n - changed = true - } - } - if !changed { - return s, false - } - out, err := json.Marshal(users) - if err != nil { - return s, false - } - return string(out), true -} - -// canonicalizePlaylistRules rewrites inPlaylist/notInPlaylist ids in smart-playlist criteria; malformed JSON passes through. -func canonicalizePlaylistRules(s string) (string, bool) { - var root map[string]any - if err := json.Unmarshal([]byte(s), &root); err != nil { - return s, false - } - if !canonicalizeRulesNode(root) { - return s, false - } - out, err := json.Marshal(root) - if err != nil { - return s, false - } - return string(out), true -} - -// canonicalizeRulesNode walks the criteria tree, canonicalizing the id of any inPlaylist/notInPlaylist object. -func canonicalizeRulesNode(node any) bool { - changed := false - switch v := node.(type) { - case map[string]any: - for k, val := range v { - if lk := strings.ToLower(k); lk == "inplaylist" || lk == "notinplaylist" { - if obj, ok := val.(map[string]any); ok { - if id, ok := obj["id"].(string); ok { - if n := canonicalID(id); n != id { - obj["id"] = n - changed = true - } - } - } - } - if canonicalizeRulesNode(val) { - changed = true - } - } - case []any: - for _, item := range v { - if canonicalizeRulesNode(item) { - changed = true - } - } - } - return changed -} - -func downUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error { - return nil // irreversible data migration -} diff --git a/db/migrations/20260722023032_add_artwork_tables.sql b/db/migrations/20260722023032_add_artwork_tables.sql deleted file mode 100644 index e307d13e8..000000000 --- a/db/migrations/20260722023032_add_artwork_tables.sql +++ /dev/null @@ -1,46 +0,0 @@ --- +goose Up -CREATE TABLE artwork ( - hash TEXT PRIMARY KEY, - mime TEXT NOT NULL, - width INTEGER NOT NULL DEFAULT 0, - height INTEGER NOT NULL DEFAULT 0, - size_bytes INTEGER NOT NULL DEFAULT 0, - blur_hash TEXT NOT NULL DEFAULT '', - thumb_hash TEXT NOT NULL DEFAULT '', - dominant_color TEXT NOT NULL DEFAULT '', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE item_artwork ( - item_kind TEXT NOT NULL, - item_id TEXT NOT NULL, - image_type TEXT NOT NULL DEFAULT 'primary', - hash TEXT NOT NULL DEFAULT '', - source TEXT NOT NULL DEFAULT '', - source_path TEXT NOT NULL DEFAULT '', - ref_mtime INTEGER NOT NULL DEFAULT 0, - attempted_at TIMESTAMP, - updated_at TIMESTAMP, - PRIMARY KEY (item_kind, item_id, image_type) -) WITHOUT ROWID; -CREATE INDEX ix_item_artwork_hash ON item_artwork(hash); - -CREATE TABLE artwork_queue ( - item_kind TEXT NOT NULL, - item_id TEXT NOT NULL, - image_type TEXT NOT NULL DEFAULT 'primary', - priority INTEGER NOT NULL DEFAULT 0, - attempts INTEGER NOT NULL DEFAULT 0, - retry_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - enqueued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (item_kind, item_id, image_type) -) WITHOUT ROWID; --- Ordered to match DequeueBatch (priority DESC, enqueued_at) so drains stop after n rows; retry_at makes it covering. --- item_kind leads: each drain pool dequeues only its own kinds, so it must seek straight to --- them rather than scan past another pool's backlog. -CREATE INDEX ix_artwork_queue_drain ON artwork_queue(item_kind, priority DESC, enqueued_at, retry_at); - --- +goose Down -DROP TABLE artwork_queue; -DROP TABLE item_artwork; -DROP TABLE artwork; diff --git a/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql b/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql deleted file mode 100644 index fd1598e93..000000000 --- a/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql +++ /dev/null @@ -1,59 +0,0 @@ --- +goose Up --- +goose StatementBegin --- Lead the unique constraint with artist_id (mirroring media_file_artists) so artist-driven --- filters and the artist-delete cascade get an index seek; album_id lookups keep album_artists_album_id. -CREATE TABLE album_artists_tmp -( - album_id varchar not null - REFERENCES album (id) - ON DELETE CASCADE, - artist_id varchar not null - REFERENCES artist (id) - ON DELETE CASCADE, - role varchar default '' not null, - sub_role varchar default '' not null, - CONSTRAINT album_artists - UNIQUE (artist_id, album_id, role, sub_role) -); - -INSERT INTO album_artists_tmp(album_id, artist_id, role, sub_role) -SELECT album_id, artist_id, role, sub_role -FROM album_artists; - -DROP TABLE album_artists; -ALTER TABLE album_artists_tmp RENAME TO album_artists; - -CREATE INDEX album_artists_album_id - ON album_artists (album_id); -CREATE INDEX album_artists_role - ON album_artists (role); --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -CREATE TABLE album_artists_tmp -( - album_id varchar not null - REFERENCES album (id) - ON DELETE CASCADE, - artist_id varchar not null - REFERENCES artist (id) - ON DELETE CASCADE, - role varchar default '' not null, - sub_role varchar default '' not null, - CONSTRAINT album_artists - UNIQUE (album_id, artist_id, role, sub_role) -); - -INSERT INTO album_artists_tmp(album_id, artist_id, role, sub_role) -SELECT album_id, artist_id, role, sub_role -FROM album_artists; - -DROP TABLE album_artists; -ALTER TABLE album_artists_tmp RENAME TO album_artists; - -CREATE INDEX album_artists_album_id - ON album_artists (album_id); -CREATE INDEX album_artists_role - ON album_artists (role); --- +goose StatementEnd diff --git a/db/migrations/20260811023338_add_item_genre_tag_indexes.sql b/db/migrations/20260811023338_add_item_genre_tag_indexes.sql deleted file mode 100644 index 1a7d75190..000000000 --- a/db/migrations/20260811023338_add_item_genre_tag_indexes.sql +++ /dev/null @@ -1,34 +0,0 @@ --- +goose Up -create table if not exists media_file_tags( - media_file_id varchar not null - references media_file (id) on delete cascade, - tag_id varchar not null - references tag (id) on delete cascade, - constraint media_file_tags unique (media_file_id, tag_id) -); -create index if not exists media_file_tags_tag_id on media_file_tags (tag_id); - -create table if not exists album_tags( - album_id varchar not null - references album (id) on delete cascade, - tag_id varchar not null - references tag (id) on delete cascade, - constraint album_tags unique (album_id, tag_id) -); -create index if not exists album_tags_tag_id on album_tags (tag_id); - --- Backfill genre rows from the per-row `tags` JSON. json_tree over the "$.genre" subtree yields one --- row per node; the "id" key nodes carry the tag ids. Single scan per table, no correlated subquery. -insert or ignore into media_file_tags (media_file_id, tag_id) -select mf.id, jt.value -from media_file mf, json_tree(mf.tags, '$.genre') jt -where jt.key = 'id' and jt.atom is not null; - -insert or ignore into album_tags (album_id, tag_id) -select al.id, jt.value -from album al, json_tree(al.tags, '$.genre') jt -where jt.key = 'id' and jt.atom is not null; - --- +goose Down -drop table if exists media_file_tags; -drop table if exists album_tags; diff --git a/db/migrations/20260815015320_add_user_scrobble_filter.sql b/db/migrations/20260815015320_add_user_scrobble_filter.sql deleted file mode 100644 index 82ed95f3d..000000000 --- a/db/migrations/20260815015320_add_user_scrobble_filter.sql +++ /dev/null @@ -1,5 +0,0 @@ --- +goose Up -alter table user add column scrobble_filter varchar default '' not null; - --- +goose Down -alter table user drop column scrobble_filter; diff --git a/db/migrations/20260816180040_renormalize_album_created_at.sql b/db/migrations/20260816180040_renormalize_album_created_at.sql deleted file mode 100644 index 9af7b2e5a..000000000 --- a/db/migrations/20260816180040_renormalize_album_created_at.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up - --- Repairs album.created_at values stored in RFC3339 T-format by CopyAttributes. --- These values sort incorrectly in "Recently Added", which compares timestamps as raw strings. - -UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') -WHERE created_at LIKE '%T%'; - --- +goose Down - -SELECT 1; diff --git a/db/migrations/20260818002312_add_playlist_imported_hash.sql b/db/migrations/20260818002312_add_playlist_imported_hash.sql deleted file mode 100644 index aab729153..000000000 --- a/db/migrations/20260818002312_add_playlist_imported_hash.sql +++ /dev/null @@ -1,5 +0,0 @@ --- +goose Up -alter table playlist add imported_hash varchar default '' not null; - --- +goose Down -alter table playlist drop column imported_hash; diff --git a/db/migrations/20260819204637_add_artwork_trace_columns.sql b/db/migrations/20260819204637_add_artwork_trace_columns.sql deleted file mode 100644 index 90fbf9725..000000000 --- a/db/migrations/20260819204637_add_artwork_trace_columns.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up -ALTER TABLE item_artwork ADD COLUMN trace jsonb NOT NULL DEFAULT '[]'; -ALTER TABLE item_artwork ADD COLUMN last_failure jsonb NOT NULL DEFAULT '[]'; -ALTER TABLE artwork_queue ADD COLUMN trace jsonb NOT NULL DEFAULT '[]'; - --- +goose Down -ALTER TABLE artwork_queue DROP COLUMN trace; -ALTER TABLE item_artwork DROP COLUMN last_failure; -ALTER TABLE item_artwork DROP COLUMN trace; diff --git a/db/migrations/20260822062750_add_user_token_epoch.sql b/db/migrations/20260822062750_add_user_token_epoch.sql deleted file mode 100644 index bd37ddeb4..000000000 --- a/db/migrations/20260822062750_add_user_token_epoch.sql +++ /dev/null @@ -1,7 +0,0 @@ --- +goose Up - -ALTER TABLE user ADD COLUMN token_epoch INTEGER NOT NULL DEFAULT 0; - --- +goose Down - -ALTER TABLE user DROP COLUMN token_epoch; diff --git a/db/migrations/20260901225726_normalize_artwork_last_failure.sql b/db/migrations/20260901225726_normalize_artwork_last_failure.sql deleted file mode 100644 index 6de12e757..000000000 --- a/db/migrations/20260901225726_normalize_artwork_last_failure.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up --- 20260819204637 added last_failure with DEFAULT '[]', so every row already in the table got a --- non-empty value. That is how a give-up is now told apart from a definitive "no image", which --- would report every pre-existing absent row as failed. -UPDATE item_artwork SET last_failure = '' WHERE last_failure = '[]'; - --- +goose Down --- Irreversible: a genuine give-up and a backfilled default are indistinguishable once normalized. -SELECT 1; diff --git a/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql b/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql deleted file mode 100644 index dced5d744..000000000 --- a/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql +++ /dev/null @@ -1,24 +0,0 @@ --- +goose Up --- +goose StatementBegin --- 32-bit builds cannot read values above the int32 range written by 64-bit builds. -update media_file set track_number = 0 -where track_number < 0 or track_number > 2147483647; - -update media_file set disc_number = 0 -where disc_number < 0 or disc_number > 2147483647; - -update media_file set bpm = null -where bpm < 0 or bpm > 2147483647; - -update album set discs = ( - select json_group_object(key, value) from json_each(album.discs) - where cast(key as integer) between 0 and 2147483647 -) -where json_valid(discs) and exists ( - select 1 from json_each(album.discs) - where cast(key as integer) not between 0 and 2147483647 -); --- +goose StatementEnd - --- +goose Down -SELECT 1; diff --git a/db/migrations/20260924010054_add_player_api_key_hash.sql b/db/migrations/20260924010054_add_player_api_key_hash.sql deleted file mode 100644 index bbc4cf4d9..000000000 --- a/db/migrations/20260924010054_add_player_api_key_hash.sql +++ /dev/null @@ -1,8 +0,0 @@ --- +goose Up --- +goose StatementBegin -alter table player add column api_key_hash varchar default null; -create unique index if not exists player_api_key_hash on player(api_key_hash); --- +goose StatementEnd - --- +goose Down -SELECT 1; diff --git a/db/migrations/20260929221042_add_library_pid_columns.sql b/db/migrations/20260929221042_add_library_pid_columns.sql deleted file mode 100644 index 487512287..000000000 --- a/db/migrations/20260929221042_add_library_pid_columns.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up --- +goose StatementBegin -alter table library add column pid_album varchar default '' not null; -alter table library add column pid_track varchar default '' not null; -alter table library add column scanned_pid_album varchar default '' not null; -alter table library add column scanned_pid_track varchar default '' not null; - --- Every library was scanned with the global PID config, so seed it as their scanned config. --- This way the upgrade does not trigger a full rescan. -update library set - scanned_pid_album = coalesce((select value from property where id = 'PIDAlbum'), ''), - scanned_pid_track = coalesce((select value from property where id = 'PIDTrack'), ''); - -delete from property where id in ('PIDAlbum', 'PIDTrack'); --- +goose StatementEnd - --- +goose Down -SELECT 1; diff --git a/db/migrations/id_canonical_test.go b/db/migrations/id_canonical_test.go deleted file mode 100644 index 0befbe469..000000000 --- a/db/migrations/id_canonical_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package migrations - -import ( - "strings" - - "github.com/navidrome/navidrome/model/id" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("canonicalID", func() { - DescribeTable("transforms each historical id shape", - func(in, want string) { - Expect(canonicalID(in)).To(Equal(want)) - }, - Entry("hash-family id (fits 128 bits) is kept", "5cLJPkLA5DK2BADhoeotPk", "5cLJPkLA5DK2BADhoeotPk"), - Entry("overflowing random id is remapped via md5", "zzzzzzzzzzzzzzzzzzzzzz", "3LyqmwQBm5IRqlVjNYASwb"), - Entry("legacy 32-hex is re-encoded value-preserving", "e3b7fc2ae9447bbec37a13bf916e3cf6", "6VHl3uR4kss6sUPKA8Cwnk"), - Entry("playlist uuid is re-encoded value-preserving", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "7rke2SAWaicSeSYzkhww6R"), - Entry("empty string passes through", "", ""), - Entry("share id (10 chars) passes through", "aB3xY9kQz1", "aB3xY9kQz1"), - Entry("truncated Finamp id (16 chars) passes through", "0123456789abcdef", "0123456789abcdef"), - Entry("22 chars with non-base62 char passes through", "!!!!!!!!!!!!!!!!!!!!!!", "!!!!!!!!!!!!!!!!!!!!!!"), - Entry("32 chars non-hex passes through", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"), - Entry("36 chars without uuid dashes passes through", "000000000000000000000000000000000000", "000000000000000000000000000000000000"), - ) - - // The exemptions for participants/tags/folder_ids/similar_artists rest on this invariant. - It("is the identity on every NewHash id", func() { - for _, parts := range [][]string{ - {""}, {"a"}, {"The Beatles"}, {"genre", "electronic"}, - {"/music/Artist/Album", "1"}, {strings.Repeat("x", 500)}, - } { - h := id.NewHash(parts...) - Expect(h).To(HaveLen(22)) - Expect(canonicalID(h)).To(Equal(h), "NewHash(%v) = %q was rewritten", parts, h) - } - }) - - It("is idempotent for every shape", func() { - for _, s := range []string{"5cLJPkLA5DK2BADhoeotPk", "zzzzzzzzzzzzzzzzzzzzzz", - "e3b7fc2ae9447bbec37a13bf916e3cf6", "f47ac10b-58cc-4372-a567-0e02b2c3d479"} { - once := canonicalID(s) - Expect(canonicalID(once)).To(Equal(once)) - } - }) -}) diff --git a/db/migrations/id_columns_guard_test.go b/db/migrations/id_columns_guard_test.go deleted file mode 100644 index c4cb3b241..000000000 --- a/db/migrations/id_columns_guard_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "os" - "strings" - - _ "github.com/mattn/go-sqlite3" - "github.com/pressly/goose/v3" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Version of the uniform-canonical-ids migration whose idColumns list this guard protects. -const idColumnsMigrationVersion = 20260720015443 - -var _ = Describe("idColumns inventory", func() { - It("covers every id-bearing column present at the migration version", func() { - ctx := context.Background() - db, err := sql.Open("sqlite3", "file::memory:") - Expect(err).ToNot(HaveOccurred()) - db.SetMaxOpenConns(1) // non-shared :memory: — a second conn would be an empty DB - DeferCleanup(func() { _ = db.Close() }) - _, err = db.ExecContext(ctx, "PRAGMA foreign_keys=off") - Expect(err).ToNot(HaveOccurred()) - - goose.SetBaseFS(os.DirFS(".")) - DeferCleanup(func() { goose.SetBaseFS(nil) }) - Expect(goose.SetDialect("sqlite3")).To(Succeed()) - Expect(goose.UpToContext(ctx, db, ".", idColumnsMigrationVersion)).To(Succeed()) - - covered := map[string]bool{} - for _, tc := range idColumns { - covered[tc.table+"."+tc.col] = true - } - for _, tc := range embeddedIDColumns { - covered[tc.table+"."+tc.col] = true - } - // Columns that are id-named or JSON but need no rewrite. - exempt := map[string]string{ - "share.id": "public share URLs, generated separately", - "property.id": "property key, not an entity id", - "plugin.id": "plugin name, not an entity id", - "media_file.participants": "hash-family artist ids, unchanged", - "album.participants": "hash-family artist ids, unchanged", - "media_file.tags": "hash-family tag ids, unchanged", - "album.tags": "hash-family tag ids, unchanged", - "album.folder_ids": "hash-family folder ids, unchanged", - "artist.similar_artists": "hash-family artist ids, unchanged", - "media_file.search_participants": "participant names for FTS, not ids", - "album.search_participants": "participant names for FTS, not ids", - "album.discs": "disc number -> title map", - "media_file.lyrics": "synced lyrics, no ids", - "folder.image_files": "image file names, no ids", - "plugin.manifest": "plugin-authored manifest, no Navidrome ids", - "plugin.config": "free-form plugin config, must not be rewritten", - "plugin.libraries": "integer library ids", - } - - tables, err := queryColumn(ctx, db, "SELECT name FROM sqlite_master WHERE type='table'") - Expect(err).ToNot(HaveOccurred()) - - var dangling []string - for _, table := range tables { - if strings.HasPrefix(table, "sqlite_") || table == "goose_db_version" || strings.Contains(table, "_fts") { - continue - } - rows, err := db.QueryContext(ctx, "SELECT name, type FROM pragma_table_info(?)", table) - Expect(err).ToNot(HaveOccurred()) - for rows.Next() { - var name, typ string - Expect(rows.Scan(&name, &typ)).To(Succeed()) - lname := strings.ToLower(name) - utyp := strings.ToUpper(typ) - // JSON can hide an id under any key, so every JSON column needs a verdict. - isJSON := strings.Contains(utyp, "JSON") - isIDName := lname == "id" || lname == "pid" || - strings.HasSuffix(lname, "_id") || strings.HasSuffix(lname, "_ids") - if !isJSON && !isIDName { - continue - } - if !isJSON && !strings.Contains(utyp, "TEXT") && !strings.Contains(utyp, "CHAR") { - continue // INTEGER ids (rowid PKs, library.id) are not canonical ids - } - if strings.HasPrefix(lname, "mbz_") { - continue // MusicBrainz UUIDs, not Navidrome ids - } - key := table + "." + name - if covered[key] || exempt[key] != "" { - continue - } - dangling = append(dangling, key) - } - Expect(rows.Err()).ToNot(HaveOccurred()) - Expect(rows.Close()).To(Succeed()) - } - Expect(dangling).To(BeEmpty(), - "id-bearing columns missing from idColumns; add them to the migration or exempt with a reason: %v", dangling) - }) -}) - -func queryColumn(ctx context.Context, db *sql.DB, query string) ([]string, error) { - rows, err := db.QueryContext(ctx, query) - if err != nil { - return nil, err - } - defer rows.Close() - var out []string - for rows.Next() { - var s string - if err := rows.Scan(&s); err != nil { - return nil, err - } - out = append(out, s) - } - return out, rows.Err() -} diff --git a/db/migrations/migration.go b/db/migrations/migration.go index df1c392a5..fde6f5817 100644 --- a/db/migrations/migration.go +++ b/db/migrations/migration.go @@ -7,20 +7,28 @@ 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(ctx context.Context, tx *sql.Tx, msg string) { - if isDBInitialized(ctx, tx) { +func notice(tx *sql.Tx, msg string) { + if isDBInitialized(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(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, fmt.Sprintf(` +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(` INSERT OR REPLACE into property (id, value) values ('%s', '1'); `, consts.FullScanAfterMigrationFlagKey)) return err @@ -36,9 +44,9 @@ var ( initialized bool ) -func isDBInitialized(ctx context.Context, tx *sql.Tx) bool { +func isDBInitialized(tx *sql.Tx) bool { once.Do(func() { - rows, err := tx.QueryContext(ctx, "select count(*) from property where id=?", consts.InitialSetupFlagKey) + rows, err := tx.Query("select count(*) from property where id=?", consts.InitialSetupFlagKey) checkErr(err) initialized = checkCount(rows) > 0 }) diff --git a/db/migrations/migrations_suite_test.go b/db/migrations/migrations_suite_test.go deleted file mode 100644 index bb08ad8aa..000000000 --- a/db/migrations/migrations_suite_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package migrations - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// tests.Init is omitted: the tests package imports db, which imports this package. -func TestMigrations(t *testing.T) { - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Migrations Suite") -} diff --git a/db/migrations/uniform_canonical_ids_test.go b/db/migrations/uniform_canonical_ids_test.go deleted file mode 100644 index ced75cb7e..000000000 --- a/db/migrations/uniform_canonical_ids_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "encoding/json" - - _ "github.com/mattn/go-sqlite3" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("upUniformCanonicalIds", func() { - var db *sql.DB - var tx *sql.Tx - ctx := context.Background() - - const ( - hashID = "5cLJPkLA5DK2BADhoeotPk" // canonical, kept - randOld = "zzzzzzzzzzzzzzzzzzzzzz" // overflows -> remapped - randNew = "3LyqmwQBm5IRqlVjNYASwb" - legacyOld = "e3b7fc2ae9447bbec37a13bf916e3cf6" // 32-hex -> re-encoded - legacyNew = "6VHl3uR4kss6sUPKA8Cwnk" - uuidOld = "f47ac10b-58cc-4372-a567-0e02b2c3d479" // uuid -> re-encoded - uuidNew = "7rke2SAWaicSeSYzkhww6R" - shareID = "aB3xY9kQz1" // exempt family - - sessionSecret = "encrypted-session-secret-sentinel" - ) - - BeforeEach(func() { - var err error - db, err = sql.Open("sqlite3", "file::memory:") - Expect(err).ToNot(HaveOccurred()) - db.SetMaxOpenConns(1) // non-shared :memory: — every new conn is a fresh empty DB - DeferCleanup(func() { _ = db.Close() }) - - // Minimal fixture: every table/column idColumns and the list rewrites touch. - _, err = db.Exec(` - CREATE TABLE media_file (id text, pid text, artist_id text, album_id text, album_artist_id text, folder_id text, mbz_recording_id text); - CREATE TABLE album (id text, album_artist_id text); - CREATE TABLE artist (id text); - CREATE TABLE folder (id text, parent_id text); - CREATE TABLE tag (id text); - CREATE TABLE library_artist (artist_id text); - CREATE TABLE user (id text); - CREATE TABLE user_props (user_id text); - CREATE TABLE playlist (id text, owner_id text, rules text); - CREATE TABLE playlist_tracks (playlist_id text, media_file_id text); - CREATE TABLE playlist_fields (playlist_id text); - CREATE TABLE annotation (user_id text, item_id text, item_type text); - CREATE TABLE bookmark (user_id text, item_id text); - CREATE TABLE player (id text, user_id text, transcoding_id text); - CREATE TABLE transcoding (id text); - CREATE TABLE radio (id text); - CREATE TABLE share (id text, user_id text, resource_ids text, contents text); - CREATE TABLE scrobble_buffer (id text, user_id text, media_file_id text); - CREATE TABLE playqueue (id text, user_id text, items text); - CREATE TABLE user_library (user_id text, library_id integer); - CREATE TABLE scrobbles (id integer, media_file_id text, user_id text); - CREATE TABLE media_file_artists (media_file_id text, artist_id text); - CREATE TABLE album_artists (album_id text, artist_id text); - CREATE TABLE library_tag (tag_id text, library_id integer); - CREATE TABLE plugin (id text, users text); - CREATE TABLE property (id text primary key, value text); - `) - Expect(err).ToNot(HaveOccurred()) - - seed := func(query string, args ...any) { - _, err := db.Exec(query, args...) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - } - // media_file: legacy id/pid, hash artist, legacy album, mbz uuid must stay untouched - seed(`INSERT INTO media_file VALUES (?, ?, ?, ?, '', '', ?)`, legacyOld, legacyOld, hashID, legacyOld, uuidOld) - seed(`INSERT INTO album VALUES (?, ?)`, legacyOld, hashID) - seed(`INSERT INTO user VALUES (?)`, randOld) - // uuid id, random owner, smart-playlist rules with an embedded inPlaylist id and a sibling operator - seed(`INSERT INTO playlist VALUES (?, ?, ?)`, uuidOld, randOld, `{"all":[{"inPlaylist":{"id":"`+uuidOld+`"}},{"inTheLast":{"lastPlayed":30}}]}`) - seed(`INSERT INTO annotation VALUES (?, ?, 'media_file')`, randOld, legacyOld) - seed(`INSERT INTO playqueue VALUES (?, ?, ?)`, randOld, randOld, legacyOld+","+hashID) - seed(`INSERT INTO share VALUES (?, ?, ?, 'Album Foo...')`, shareID, randOld, legacyOld+","+uuidOld) - seed(`INSERT INTO user_library VALUES (?, 1)`, randOld) - seed(`INSERT INTO scrobbles VALUES (1, ?, ?)`, legacyOld, randOld) - seed(`INSERT INTO media_file_artists VALUES (?, ?)`, legacyOld, hashID) - seed(`INSERT INTO album_artists VALUES (?, ?)`, legacyOld, hashID) - seed(`INSERT INTO library_tag VALUES (?, 1)`, hashID) - seed(`INSERT INTO plugin VALUES ('lastfm', ?)`, `["`+randOld+`","`+hashID+`"]`) - seed(`INSERT INTO plugin VALUES ('empty', '[]')`) // exempt: empty user list untouched - // malformed JSON in both a plugin list and a playlist rule: must pass through byte-for-byte - seed(`INSERT INTO plugin VALUES ('broken', 'not-json')`) - seed(`INSERT INTO playlist VALUES (?, ?, '{broken')`, hashID, hashID) - seed(`INSERT INTO property VALUES (?, ?)`, consts.JWTSecretKey, sessionSecret) - }) - - JustBeforeEach(func() { - var err error - tx, err = db.Begin() - Expect(err).ToNot(HaveOccurred()) - Expect(upUniformCanonicalIds(ctx, tx)).To(Succeed()) - Expect(tx.Commit()).To(Succeed()) - }) - - get := func(query string) string { - var s string - ExpectWithOffset(1, db.QueryRow(query).Scan(&s)).To(Succeed()) - return s - } - - It("canonicalizes every id family and keeps references consistent", func() { - Expect(get(`SELECT id FROM media_file`)).To(Equal(legacyNew)) - Expect(get(`SELECT pid FROM media_file`)).To(Equal(legacyNew)) - Expect(get(`SELECT artist_id FROM media_file`)).To(Equal(hashID)) - Expect(get(`SELECT album_id FROM media_file`)).To(Equal(legacyNew)) - Expect(get(`SELECT id FROM album`)).To(Equal(legacyNew)) - Expect(get(`SELECT id FROM user`)).To(Equal(randNew)) - Expect(get(`SELECT id FROM playlist WHERE owner_id='` + randNew + `'`)).To(Equal(uuidNew)) - Expect(get(`SELECT owner_id FROM playlist WHERE id='` + uuidNew + `'`)).To(Equal(randNew)) - Expect(get(`SELECT item_id FROM annotation`)).To(Equal(legacyNew)) - Expect(get(`SELECT user_id FROM annotation`)).To(Equal(randNew)) - }) - - It("rewrites list columns element-wise", func() { - Expect(get(`SELECT items FROM playqueue`)).To(Equal(legacyNew + "," + hashID)) - Expect(get(`SELECT resource_ids FROM share`)).To(Equal(legacyNew + "," + uuidNew)) - }) - - It("leaves exempt columns alone", func() { - Expect(get(`SELECT id FROM share`)).To(Equal(shareID)) - Expect(get(`SELECT contents FROM share`)).To(Equal("Album Foo...")) - Expect(get(`SELECT mbz_recording_id FROM media_file`)).To(Equal(uuidOld)) - }) - - It("canonicalizes junction and membership tables", func() { - Expect(get(`SELECT user_id FROM user_library`)).To(Equal(randNew)) - Expect(get(`SELECT media_file_id FROM scrobbles`)).To(Equal(legacyNew)) - Expect(get(`SELECT user_id FROM scrobbles`)).To(Equal(randNew)) - Expect(get(`SELECT media_file_id FROM media_file_artists`)).To(Equal(legacyNew)) - Expect(get(`SELECT artist_id FROM media_file_artists`)).To(Equal(hashID)) // hash-family, unchanged - Expect(get(`SELECT album_id FROM album_artists`)).To(Equal(legacyNew)) - Expect(get(`SELECT artist_id FROM album_artists`)).To(Equal(hashID)) // hash-family, unchanged - Expect(get(`SELECT tag_id FROM library_tag`)).To(Equal(hashID)) // hash-family, unchanged - }) - - It("rewrites JSON columns element-wise", func() { - Expect(get(`SELECT users FROM plugin WHERE id='lastfm'`)).To(Equal(`["` + randNew + `","` + hashID + `"]`)) - Expect(get(`SELECT id FROM plugin WHERE id='lastfm'`)).To(Equal("lastfm")) // plugin name, untouched - - var rules map[string]any - Expect(json.Unmarshal([]byte(get(`SELECT rules FROM playlist WHERE id='`+uuidNew+`'`)), &rules)).To(Succeed()) - all, ok := rules["all"].([]any) - Expect(ok).To(BeTrue()) - Expect(all).To(HaveLen(2)) - inPl := all[0].(map[string]any)["inPlaylist"].(map[string]any) - Expect(inPl["id"]).To(Equal(uuidNew)) - }) - - It("preserves sibling operators alongside a rewritten inPlaylist id", func() { - var rules map[string]any - Expect(json.Unmarshal([]byte(get(`SELECT rules FROM playlist WHERE id='`+uuidNew+`'`)), &rules)).To(Succeed()) - all := rules["all"].([]any) - var sawInPlaylist, sawInTheLast bool - for _, e := range all { - op := e.(map[string]any) - if pl, ok := op["inPlaylist"].(map[string]any); ok { - Expect(pl["id"]).To(Equal(uuidNew)) - sawInPlaylist = true - } - if last, ok := op["inTheLast"].(map[string]any); ok { - Expect(last["lastPlayed"]).To(Equal(float64(30))) - sawInTheLast = true - } - } - Expect(sawInPlaylist).To(BeTrue()) - Expect(sawInTheLast).To(BeTrue()) - }) - - It("leaves exempt JSON rows untouched", func() { - Expect(get(`SELECT users FROM plugin WHERE id='empty'`)).To(Equal("[]")) - }) - - It("passes malformed JSON columns through byte-for-byte", func() { - Expect(get(`SELECT users FROM plugin WHERE id='broken'`)).To(Equal("not-json")) - Expect(get(`SELECT rules FROM playlist WHERE id='` + hashID + `'`)).To(Equal("{broken")) - }) - - rescanCount := func() int { - var count int - ExpectWithOffset(1, db.QueryRow( - `SELECT count(*) FROM property WHERE id = ?`, consts.FullScanAfterMigrationFlagKey).Scan(&count)).To(Succeed()) - return count - } - - It("does not force a full rescan for the default PID config", func() { - Expect(rescanCount()).To(Equal(0)) - }) - - Context("with a legacy PID configuration", func() { - BeforeEach(func() { - prev := conf.Server.PID.Album - conf.Server.PID.Album = "album_legacy" - DeferCleanup(func() { conf.Server.PID.Album = prev }) - }) - - It("forces a full rescan so composite pids are rewritten", func() { - Expect(rescanCount()).To(Equal(1)) - }) - }) - - propCount := func(key string) int { - var count int - ExpectWithOffset(1, db.QueryRow(`SELECT count(*) FROM property WHERE id = ?`, key).Scan(&count)).To(Succeed()) - return count - } - - It("rotates the session secret to the public key", func() { - Expect(propCount(consts.JWTSecretKey)).To(Equal(0)) - Expect(get(`SELECT value FROM property WHERE id = '` + consts.JWTPublicSecretKey + `'`)).To(Equal(sessionSecret)) - }) - - Context("with no stored session secret", func() { - BeforeEach(func() { - _, err := db.Exec(`DELETE FROM property WHERE id = ?`, consts.JWTSecretKey) - Expect(err).ToNot(HaveOccurred()) - }) - - It("is a no-op", func() { - Expect(propCount(consts.JWTSecretKey)).To(Equal(0)) - Expect(propCount(consts.JWTPublicSecretKey)).To(Equal(0)) - }) - }) -}) diff --git a/db/optimize.go b/db/optimize.go deleted file mode 100644 index aff36e8fd..000000000 --- a/db/optimize.go +++ /dev/null @@ -1,274 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strconv" - "strings" - "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") - if err := analyzeInSteps(ctx, db); 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 -} - -// One ANALYZE per index (whole table if WITHOUT ROWID or lacking a non-partial index) yields the -// same sqlite_stat1 rows as a full ANALYZE, but frees the write lock between steps. -const analyzeTargetsSQL = ` -SELECT i.name FROM sqlite_schema i JOIN pragma_table_list t ON t.schema = 'main' AND t.name = i.tbl_name -WHERE i.type = 'index' AND t.wr = 0 AND EXISTS (SELECT 1 FROM pragma_index_list(t.name) l WHERE l.partial = 0) -UNION ALL -SELECT t.name FROM pragma_table_list t -WHERE t.schema = 'main' AND t.type IN ('table', 'shadow') AND t.name NOT LIKE 'sqlite_%' - AND (t.wr = 1 OR NOT EXISTS (SELECT 1 FROM pragma_index_list(t.name) l WHERE l.partial = 0))` - -// analyzeMaxYield is just above SQLite's longest busy-handler sleep, so every waiting writer -// retries during the pause. -const analyzeMaxYield = 150 * time.Millisecond - -func analyzeInSteps(ctx context.Context, db *sql.DB) error { - targets, err := analyzeTargets(ctx, db) - if err != nil { - return err - } - for _, target := range targets { - start := time.Now() - if _, err := db.ExecContext(ctx, `ANALYZE "`+strings.ReplaceAll(target, `"`, `""`)+`"`); err != nil { - return fmt.Errorf("analyzing %s: %w", target, err) - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(min(time.Since(start), analyzeMaxYield)): - } - } - return nil -} - -func analyzeTargets(ctx context.Context, db *sql.DB) ([]string, error) { - rows, err := db.QueryContext(ctx, analyzeTargetsSQL) - if err != nil { - return nil, fmt.Errorf("listing ANALYZE targets: %w", err) - } - defer rows.Close() - var targets []string - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return nil, fmt.Errorf("listing ANALYZE targets: %w", err) - } - targets = append(targets, name) - } - return targets, rows.Err() -} - -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 deleted file mode 100644 index 6ad9b9cb4..000000000 --- a/db/optimize_test.go +++ /dev/null @@ -1,199 +0,0 @@ -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("produces the same statistics as a single full ANALYZE", func() { - putProperty(consts.DBAnalyzePendingKey, "1") - for _, stmt := range []string{ - "create table unindexed(id integer primary key, v int)", - "insert into unindexed(v) select flag from analyze_probe", - "create table no_rowid(k text primary key, v int) without rowid", - "insert into no_rowid select 'k' || id, id % 7 from analyze_probe", - "create index no_rowid_v on no_rowid(v)", - "create table partial_only(id integer primary key, v int)", - "insert into partial_only(v) select id % 5 from analyze_probe", - "create index partial_only_v on partial_only(v) where v = 1", - "analyze", - } { - _, err := database.Exec(stmt) - Expect(err).ToNot(HaveOccurred()) - } - statRows := func() []string { - rows, err := database.Query("select tbl || '|' || coalesce(idx, '') || '|' || stat from sqlite_stat1 order by 1") - Expect(err).ToNot(HaveOccurred()) - defer rows.Close() - var res []string - for rows.Next() { - var s string - Expect(rows.Scan(&s)).To(Succeed()) - res = append(res, s) - } - return res - } - fullAnalyze := statRows() - _, err := database.Exec("delete from sqlite_stat1") - Expect(err).ToNot(HaveOccurred()) - - Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed()) - - Expect(statRows()).To(Equal(fullAnalyze)) - }) - - 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/db/repair.go b/db/repair.go deleted file mode 100644 index 7f8b8d044..000000000 --- a/db/repair.go +++ /dev/null @@ -1,367 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "errors" - "fmt" - "slices" - "strings" -) - -var ftsTables = []string{"media_file_fts", "album_fts", "artist_fts"} - -var ftsTriggerSuffixes = []string{"_ai", "_ad", "_au"} - -// integrityCheckMaxIssues bounds the problems reported; IntegrityCheck asks the -// pragma for one extra row, because it truncates without emitting any marker. -const integrityCheckMaxIssues = 100 - -// IntegrityCheck runs PRAGMA integrity_check and returns the problems it reports, or -// an empty slice when healthy. The second value marks a list that was cut short. -func IntegrityCheck(ctx context.Context, database *sql.DB) ([]string, bool, error) { - rows, err := database.QueryContext(ctx, - fmt.Sprintf("PRAGMA integrity_check(%d)", integrityCheckMaxIssues+1)) - if err != nil { - return nil, false, fmt.Errorf("running integrity_check: %w", err) - } - defer rows.Close() - - var issues []string - for rows.Next() { - var line string - if err := rows.Scan(&line); err != nil { - return nil, false, fmt.Errorf("reading integrity_check results: %w", err) - } - issues = append(issues, line) - } - if err := rows.Err(); err != nil { - return nil, false, fmt.Errorf("reading integrity_check results: %w", err) - } - if len(issues) == 1 && issues[0] == "ok" { - return nil, false, nil - } - if len(issues) > integrityCheckMaxIssues { - return issues[:integrityCheckMaxIssues], true, nil - } - return issues, false, nil -} - -// FKViolation counts the rows in Table that reference missing rows in Parent. -type FKViolation struct { - Table string - Parent string - Count int64 -} - -// ForeignKeyCheck runs PRAGMA foreign_key_check, aggregated per (table, parent) pair -// because the raw pragma emits one row per orphan, unbounded on a large library. -func ForeignKeyCheck(ctx context.Context, database *sql.DB) ([]FKViolation, error) { - rows, err := database.QueryContext(ctx, - `SELECT "table", "parent", count(*) FROM pragma_foreign_key_check GROUP BY "table", "parent"`) - if err != nil { - return nil, fmt.Errorf("running foreign_key_check: %w", err) - } - defer rows.Close() - - var violations []FKViolation - for rows.Next() { - var v FKViolation - if err := rows.Scan(&v.Table, &v.Parent, &v.Count); err != nil { - return nil, fmt.Errorf("reading foreign_key_check results: %w", err) - } - violations = append(violations, v) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("reading foreign_key_check results: %w", err) - } - return violations, nil -} - -// IsFTSCorruptionOnly reports whether every integrity issue refers to one of the -// FTS5 search tables, meaning RebuildFTS can fully repair the database. -func IsFTSCorruptionOnly(issues []string) bool { - if len(issues) == 0 { - return false - } - for _, line := range issues { - if !slices.ContainsFunc(ftsTables, func(table string) bool { return strings.Contains(line, table) }) { - return false - } - } - return true -} - -// execer is the subset of *sql.DB and *sql.Tx that verifyFTS needs. -type execer interface { - ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) -} - -// VerifyFTS runs the FTS5 'integrity-check' command on each search table. Unlike a -// full PRAGMA integrity_check, it reads only the FTS indexes, not the whole database. -func VerifyFTS(ctx context.Context, database *sql.DB) error { - return verifyFTS(ctx, database) -} - -func verifyFTS(ctx context.Context, database execer) error { - for _, table := range ftsTables { - stmt := fmt.Sprintf("INSERT INTO %[1]s(%[1]s) VALUES('integrity-check')", table) //nolint:gosec // fixed table list - if _, err := database.ExecContext(ctx, stmt); err != nil { - return fmt.Errorf("verifying %s: %w", table, err) - } - } - return nil -} - -const ftsSearchMigration int64 = 20260220173400 - -var errNotMigrated = errors.New("the FTS search migration has not been applied yet; start Navidrome once to migrate the database first") - -// requireFTSMigration fails unless the FTS search migration has run. The goose table -// is probed separately because a query against a missing table fails at prepare time. -func requireFTSMigration(ctx context.Context, database *sql.DB) error { - migrated, err := hasGooseTable(ctx, database) - if err != nil { - return fmt.Errorf("checking FTS migration status: %w", err) - } - if !migrated { - return errNotMigrated - } - var applied int - if err := database.QueryRowContext(ctx, - "SELECT count(*) FROM goose_db_version WHERE version_id = ?", ftsSearchMigration).Scan(&applied); err != nil { - return fmt.Errorf("checking FTS migration status: %w", err) - } - if applied == 0 { - return errNotMigrated - } - return nil -} - -// RebuildFTS drops the FTS5 search tables and their triggers, recreates them from the -// base tables, and verifies the result before committing. The tables are contentless, -// so no user data is lost. It needs only the FTS migration, not a fully migrated -// schema, because a corrupted DB often cannot run pending migrations. -func RebuildFTS(ctx context.Context, database *sql.DB) error { - if err := requireFTSMigration(ctx, database); err != nil { - return err - } - - tx, err := database.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("starting FTS rebuild transaction: %w", err) - } - defer func() { _ = tx.Rollback() }() - - var stmts []string - for _, table := range ftsTables { - for _, suffix := range ftsTriggerSuffixes { - stmts = append(stmts, "DROP TRIGGER IF EXISTS "+table+suffix) - } - stmts = append(stmts, "DROP TABLE IF EXISTS "+table) - } - stmts = append(stmts, ftsSchemaDDL...) - for _, stmt := range stmts { - if _, err := tx.ExecContext(ctx, stmt); err != nil { - return fmt.Errorf("rebuilding FTS schema: %w", err) - } - } - if err := verifyFTS(ctx, tx); err != nil { - return fmt.Errorf("the rebuilt search index did not verify: %w", err) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("committing FTS rebuild: %w", err) - } - return nil -} - -// ftsSchemaDDL must reproduce what the full migration chain produces, not what any -// single migration does; the schema comparison in repair_test.go guards the drift. -var ftsSchemaDDL = []string{ - ` - CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5( - title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized, - content='', content_rowid='rowid', - tokenize='unicode61 remove_diacritics 2' - ) - `, - ` - CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5( - name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized, - content='', content_rowid='rowid', - tokenize='unicode61 remove_diacritics 2' - ) - `, - ` - CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5( - name, sort_artist_name, search_normalized, - content='', content_rowid='rowid', - tokenize='unicode61 remove_diacritics 2' - ) - `, - ` - INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - SELECT rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''), - COALESCE(search_normalized, '') - FROM media_file - `, - ` - INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''), - COALESCE(search_participants, ''), COALESCE(discs, ''), - COALESCE(catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(album.tags, '$.albumversion') AS je), ''), - COALESCE(search_normalized, '') - FROM album - `, - ` - INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) - SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '') - FROM artist - `, - ` - CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN - INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, - NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, - COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), - COALESCE(NEW.search_normalized, '')); - END - `, - ` - CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN - INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, - OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, - COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), - COALESCE(OLD.search_normalized, '')); - END - `, - ` - CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file - WHEN - OLD.title IS NOT NEW.title OR - OLD.album IS NOT NEW.album OR - OLD.artist IS NOT NEW.artist OR - OLD.album_artist IS NOT NEW.album_artist OR - OLD.sort_title IS NOT NEW.sort_title OR - OLD.sort_album_name IS NOT NEW.sort_album_name OR - OLD.sort_artist_name IS NOT NEW.sort_artist_name OR - OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR - OLD.disc_subtitle IS NOT NEW.disc_subtitle OR - OLD.search_participants IS NOT NEW.search_participants OR - OLD.search_normalized IS NOT NEW.search_normalized - BEGIN - INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, - OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, - COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), - COALESCE(OLD.search_normalized, '')); - INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, - NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, - COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), - COALESCE(NEW.search_normalized, '')); - END - `, - ` - CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN - INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), - COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), - COALESCE(NEW.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(NEW.tags, '$.albumversion') AS je), ''), - COALESCE(NEW.search_normalized, '')); - END - `, - ` - CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN - INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), - COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), - COALESCE(OLD.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(OLD.tags, '$.albumversion') AS je), ''), - COALESCE(OLD.search_normalized, '')); - END - `, - ` - CREATE TRIGGER album_fts_au AFTER UPDATE ON album - WHEN - OLD.name IS NOT NEW.name OR - OLD.sort_album_name IS NOT NEW.sort_album_name OR - OLD.album_artist IS NOT NEW.album_artist OR - OLD.search_participants IS NOT NEW.search_participants OR - OLD.discs IS NOT NEW.discs OR - OLD.catalog_num IS NOT NEW.catalog_num OR - OLD.tags IS NOT NEW.tags OR - OLD.search_normalized IS NOT NEW.search_normalized - BEGIN - INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), - COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), - COALESCE(OLD.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(OLD.tags, '$.albumversion') AS je), ''), - COALESCE(OLD.search_normalized, '')); - INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), - COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), - COALESCE(NEW.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(NEW.tags, '$.albumversion') AS je), ''), - COALESCE(NEW.search_normalized, '')); - END - `, - ` - CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN - INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), - COALESCE(NEW.search_normalized, '')); - END - `, - ` - CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN - INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), - COALESCE(OLD.search_normalized, '')); - END - `, - ` - CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist - WHEN - OLD.name IS NOT NEW.name OR - OLD.sort_artist_name IS NOT NEW.sort_artist_name OR - OLD.search_normalized IS NOT NEW.search_normalized - BEGIN - INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), - COALESCE(OLD.search_normalized, '')); - INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), - COALESCE(NEW.search_normalized, '')); - END - `, -} diff --git a/db/repair_test.go b/db/repair_test.go deleted file mode 100644 index eb7321939..000000000 --- a/db/repair_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package db_test - -import ( - "context" - "database/sql" - "fmt" - "path/filepath" - "regexp" - "strings" - - "github.com/navidrome/navidrome/db" - "github.com/pressly/goose/v3" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// newDB returns an in-memory database migrated up to the given goose version -// (0 = fully migrated). -func newDB(ctx context.Context, upTo int64) *sql.DB { - GinkgoHelper() - d, err := sql.Open(db.Dialect, "file::memory:") - Expect(err).ToNot(HaveOccurred()) - d.SetMaxOpenConns(1) // non-shared :memory:, a second conn would be an empty DB - DeferCleanup(func() { _ = d.Close() }) - - _, err = d.ExecContext(ctx, "PRAGMA foreign_keys=off") - Expect(err).ToNot(HaveOccurred()) - goose.SetBaseFS(db.EmbedMigrations) - goose.SetLogger(goose.NopLogger()) - DeferCleanup(func() { goose.SetBaseFS(nil) }) - Expect(goose.SetDialect(db.Dialect)).To(Succeed()) - if upTo == 0 { - Expect(goose.UpContext(ctx, d, "migrations")).To(Succeed()) - } else { - Expect(goose.UpToContext(ctx, d, "migrations", upTo)).To(Succeed()) - } - return d -} - -// openMismatchedIndexDB builds a database whose index is declared over a different -// column than the one it was populated from, so integrity_check reports one issue per row. -func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB { - GinkgoHelper() - path := filepath.Join(GinkgoT().TempDir(), "mismatched.db") - open := func() *sql.DB { - d, err := sql.Open(db.Dialect, path) - Expect(err).ToNot(HaveOccurred()) - d.SetMaxOpenConns(1) - return d - } - d := open() - for _, stmt := range []string{ - `create table t(a, b)`, - fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d) - insert into t select x, x + 10000 from s`, rows), - `create index i on t(a)`, - `pragma writable_schema=on`, - `update sqlite_master set sql = 'CREATE INDEX i ON t(b)' where name = 'i'`, - } { - _, err := d.ExecContext(ctx, stmt) - Expect(err).ToNot(HaveOccurred()) - } - Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema - - d = open() - DeferCleanup(func() { _ = d.Close() }) - return d -} - -var _ = Describe("IsFTSCorruptionOnly", func() { - It("is true when every issue mentions an FTS search table", func() { - Expect(db.IsFTSCorruptionOnly([]string{ - `fts5: corruption found reading blob 42 from table "media_file_fts"`, - `malformed inverted index for FTS5 table main.album_fts`, - `fts5: corruption in "artist_fts"`, - })).To(BeTrue()) - }) - - It("is false when any issue is outside the FTS search tables", func() { - Expect(db.IsFTSCorruptionOnly([]string{ - `fts5: corruption found reading blob 42 from table "media_file_fts"`, - `*** in database main ***`, - })).To(BeFalse()) - }) - - It("is false when there are no issues", func() { - Expect(db.IsFTSCorruptionOnly(nil)).To(BeFalse()) - }) -}) - -var _ = Describe("RebuildFTS schema guard", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - }) - - It("refuses to run on a schema older than the FTS migration", func() { - old := newDB(ctx, db.FTSSearchMigration-1) - - err := db.RebuildFTS(ctx, old) - Expect(err).To(MatchError(ContainSubstring("migration"))) - }) - - It("refuses to run on a database that was never migrated", func() { - empty, err := sql.Open(db.Dialect, "file::memory:") - Expect(err).ToNot(HaveOccurred()) - empty.SetMaxOpenConns(1) - DeferCleanup(func() { _ = empty.Close() }) - - Expect(db.RebuildFTS(ctx, empty)).To(MatchError(ContainSubstring("start Navidrome once"))) - }) - - It("runs on a post-FTS schema even when newer migrations are pending", func() { - behind := newDB(ctx, 20260702152457) - - Expect(db.RebuildFTS(ctx, behind)).To(Succeed()) - }) -}) - -var _ = Describe("Repair", func() { - var ( - ctx context.Context - database *sql.DB - ) - - BeforeEach(func() { - ctx = context.Background() - database = newDB(ctx, 0) - - for _, stmt := range []string{ - `insert into artist(id, name, search_normalized) values ('ar-1', 'Ramones', 'ramones')`, - `insert into album(id, name, search_normalized) values ('al-1', 'Rocket to Russia', 'rocket to russia')`, - `insert into media_file(id, title, search_normalized) values ('mf-1', 'Teenage Lobotomy', 'teenage lobotomy')`, - `insert into media_file(id, title, search_normalized) values ('mf-2', 'Rockaway Beach', 'rockaway beach')`, - } { - _, err := database.ExecContext(ctx, stmt) - Expect(err).ToNot(HaveOccurred()) - } - }) - - corruptFTS := func(table string) { - // 8+ bytes of garbage: a 4-byte blob still parses as a valid empty structure record - _, err := database.ExecContext(ctx, `update `+table+`_data set block = x'deadbeefdeadbeef' where id > 1`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - } - - searchFTS := func(table, term string) int { - var count int - err := database.QueryRowContext(ctx, `select count(*) from `+table+` where `+table+` match ?`, term).Scan(&count) - Expect(err).ToNot(HaveOccurred()) - return count - } - - // ftsSchema returns name -> whitespace-normalized DDL for the FTS tables, - // their shadow tables, and their triggers. - ftsSchema := func() map[string]string { - rows, err := database.QueryContext(ctx, - `select name, sql from sqlite_master where name like '%_fts%' and sql is not null`) - Expect(err).ToNot(HaveOccurred()) - defer rows.Close() - ws := regexp.MustCompile(`\s+`) - schema := map[string]string{} - for rows.Next() { - var name, ddl string - Expect(rows.Scan(&name, &ddl)).To(Succeed()) - schema[name] = ws.ReplaceAllString(ddl, " ") - } - Expect(rows.Err()).ToNot(HaveOccurred()) - return schema - } - - Describe("IntegrityCheck", func() { - It("returns no issues for a healthy database", func() { - issues, truncated, err := db.IntegrityCheck(ctx, database) - Expect(err).ToNot(HaveOccurred()) - Expect(issues).To(BeEmpty()) - Expect(truncated).To(BeFalse()) - }) - - It("reports corruption in an FTS index", func() { - corruptFTS("media_file_fts") - issues, truncated, err := db.IntegrityCheck(ctx, database) - Expect(err).ToNot(HaveOccurred()) - Expect(issues).ToNot(BeEmpty()) - Expect(strings.Join(issues, "\n")).To(ContainSubstring("media_file_fts")) - Expect(truncated).To(BeFalse()) - }) - - It("flags the issue list as truncated when there are more issues than the limit", func() { - broken := openMismatchedIndexDB(ctx, 300) - - issues, truncated, err := db.IntegrityCheck(ctx, broken) - Expect(err).ToNot(HaveOccurred()) - Expect(issues).To(HaveLen(100)) - Expect(truncated).To(BeTrue()) - }) - - It("does not flag truncation when the issues exactly fill the limit", func() { - broken := openMismatchedIndexDB(ctx, 100) - - issues, truncated, err := db.IntegrityCheck(ctx, broken) - Expect(err).ToNot(HaveOccurred()) - Expect(issues).To(HaveLen(100)) - Expect(truncated).To(BeFalse()) - }) - }) - - Describe("ForeignKeyCheck", func() { - It("returns no violations for a healthy database", func() { - violations, err := db.ForeignKeyCheck(ctx, database) - Expect(err).ToNot(HaveOccurred()) - Expect(violations).To(BeEmpty()) - }) - - It("reports rows referencing missing parents", func() { - _, err := database.ExecContext(ctx, - `insert into media_file(id, title, library_id) values ('mf-bad', 'Orphan', 999)`) - Expect(err).ToNot(HaveOccurred()) - - violations, err := db.ForeignKeyCheck(ctx, database) - Expect(err).ToNot(HaveOccurred()) - Expect(violations).To(HaveLen(1)) - Expect(violations[0].Table).To(Equal("media_file")) - Expect(violations[0].Parent).To(Equal("library")) - Expect(violations[0].Count).To(BeNumerically("==", 1)) - }) - }) - - Describe("VerifyFTS", func() { - It("passes on a healthy index", func() { - Expect(db.VerifyFTS(ctx, database)).To(Succeed()) - }) - - It("fails on a corrupted index, naming the table", func() { - corruptFTS("album_fts") - Expect(db.VerifyFTS(ctx, database)).To(MatchError(ContainSubstring("album_fts"))) - }) - }) - - Describe("RebuildFTS", func() { - It("repairs a corrupted FTS index", func() { - corruptFTS("media_file_fts") - - Expect(db.RebuildFTS(ctx, database)).To(Succeed()) - - issues, _, err := db.IntegrityCheck(ctx, database) - Expect(err).ToNot(HaveOccurred()) - Expect(issues).To(BeEmpty()) - Expect(db.VerifyFTS(ctx, database)).To(Succeed()) - Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1)) - Expect(searchFTS("album_fts", "russia")).To(Equal(1)) - Expect(searchFTS("artist_fts", "ramones")).To(Equal(1)) - }) - - It("recreates tables and triggers dropped by hand", func() { - for _, table := range db.FTSTables { - for _, suffix := range db.FTSTriggerSuffixes { - _, err := database.ExecContext(ctx, "drop trigger "+table+suffix) - Expect(err).ToNot(HaveOccurred()) - } - _, err := database.ExecContext(ctx, "drop table "+table) - Expect(err).ToNot(HaveOccurred()) - } - - Expect(db.RebuildFTS(ctx, database)).To(Succeed()) - - Expect(searchFTS("media_file_fts", "rockaway")).To(Equal(1)) - }) - - It("rolls back and keeps the old index when the rebuild fails", func() { - // Triggers go first: SQLite refuses to drop a column they reference. - for _, suffix := range db.FTSTriggerSuffixes { - _, err := database.ExecContext(ctx, "drop trigger media_file_fts"+suffix) - Expect(err).ToNot(HaveOccurred()) - } - _, err := database.ExecContext(ctx, `alter table media_file drop column disc_subtitle`) - Expect(err).ToNot(HaveOccurred()) - - Expect(db.RebuildFTS(ctx, database)).ToNot(Succeed()) - - Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1)) - Expect(searchFTS("album_fts", "russia")).To(Equal(1)) - }) - - It("produces the same schema as the migration", func() { - migrated := ftsSchema() - Expect(migrated).ToNot(BeEmpty()) - - Expect(db.RebuildFTS(ctx, database)).To(Succeed()) - - Expect(ftsSchema()).To(Equal(migrated)) - }) - - It("leaves working triggers behind", func() { - Expect(db.RebuildFTS(ctx, database)).To(Succeed()) - - _, err := database.ExecContext(ctx, - `insert into artist(id, name, search_normalized) values ('ar-2', 'Blondie', 'blondie')`) - Expect(err).ToNot(HaveOccurred()) - Expect(searchFTS("artist_fts", "blondie")).To(Equal(1)) - - _, err = database.ExecContext(ctx, `delete from artist where id = 'ar-2'`) - Expect(err).ToNot(HaveOccurred()) - Expect(searchFTS("artist_fts", "blondie")).To(BeZero()) - }) - }) -}) diff --git a/go.mod b/go.mod index e96b8c8b3..6a0acf2d6 100644 --- a/go.mod +++ b/go.mod @@ -1,73 +1,70 @@ module github.com/navidrome/navidrome -go 1.27 +go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a require ( github.com/Masterminds/squirrel v1.5.4 - github.com/andybalholm/cascadia v1.3.5 - github.com/bmatcuk/doublestar/v4 v4.10.2 - github.com/deluan/rest v1.0.1 + github.com/andybalholm/cascadia v1.3.3 + 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 - github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 + github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 github.com/djherbis/atime v1.1.0 - github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 - github.com/djherbis/stream v1.5.1 + github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 + github.com/djherbis/stream v1.4.0 github.com/djherbis/times v1.6.0 - github.com/dustin/go-humanize v1.1.0 + github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 - github.com/gen2brain/webp v0.6.4 - github.com/getkin/kin-openapi v0.149.0 - github.com/go-chi/chi/v5 v5.3.2 + github.com/gen2brain/webp v0.5.5 + github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 - github.com/go-chi/httprate v0.16.0 + github.com/go-chi/httprate v0.15.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 v1.1.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.1 - github.com/kardianos/service v1.3.0 + github.com/jellydator/ttlcache/v3 v3.4.0 + github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.3.0 - github.com/mattn/go-sqlite3 v1.14.52 + github.com/lestrrat-go/jwx/v3 v3.1.1 + github.com/mattn/go-sqlite3 v1.14.44 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.33.0 - github.com/onsi/gomega v1.44.0 - github.com/pelletier/go-toml/v2 v2.4.3 + 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/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.28.0 - github.com/prometheus/client_golang v1.24.1 + github.com/pressly/goose/v3 v3.27.1 + github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 - github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 - github.com/sirupsen/logrus v1.10.2 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.12.1 - github.com/tetratelabs/wazero v1.12.0 + github.com/stretchr/testify v1.11.1 + github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 - github.com/zeebo/xxh3 v1.1.0 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.46.0 - golang.org/x/net v0.59.0 - golang.org/x/sync v0.23.0 - golang.org/x/sys v0.48.0 - golang.org/x/term v0.46.0 - golang.org/x/text v0.42.0 - golang.org/x/time v0.16.0 + golang.org/x/image v0.40.0 + golang.org/x/net v0.54.0 + golang.org/x/sync v0.20.0 + golang.org/x/sys v0.44.0 + golang.org/x/term v0.43.0 + golang.org/x/text v0.37.0 + golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -77,55 +74,52 @@ 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.2 // indirect + github.com/cespare/reflex v0.3.1 // 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.11.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/go-logr/logr v1.4.4 // indirect - github.com/go-openapi/jsonpointer v0.22.5 // indirect - github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/gobwas/glob v1.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-20260906184651-6331bc6350fe // indirect + github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/gorilla/mux v1.8.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 // 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.4.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.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 github.com/lestrrat-go/blackmagic v1.0.4 // indirect - github.com/lestrrat-go/dsig v1.4.0 // indirect + 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.6 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.5 // 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 github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oasdiff/yaml v0.1.1 // indirect - github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/ogier/pflag v0.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/procfs v0.22.0 // indirect - github.com/rogpeppe/go-internal v1.16.0 // 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/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect github.com/segmentio/asm v1.2.1 // indirect - github.com/sethvargo/go-retry v0.4.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -134,15 +128,17 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/valyala/fastjson v1.6.10 // indirect - go.opentelemetry.io/proto/otlp v1.11.0 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.57.0 // indirect - golang.org/x/mod v0.41.0 // indirect - golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 // indirect - golang.org/x/tools v0.50.0 // indirect - google.golang.org/protobuf v1.36.12 // indirect - gopkg.in/ini.v1 v1.67.3 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index fe6dbbc3f..0c550e47d 100644 --- a/go.sum +++ b/go.sum @@ -6,121 +6,118 @@ 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.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8= -github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= +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/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= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 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.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw= -github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -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/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/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= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +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-20260913142955-d55e0c9353cb h1:CGVY6RtDsqaleUFogGP03m3a/9OKi3ZMDr5nhm51Emk= -github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= -github.com/deluan/rest v1.0.1 h1:Enuzzfd88C1/lG6Jqr2NgRreJoClaFuWcOuspeKWHZ8= -github.com/deluan/rest v1.0.1/go.mod h1:r0yO0VgBWOb5Xb7aCPIedtcOuwmA2Oe5lGailC5pvc4= +github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw= +github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a/go.mod h1:+k5CamBu88xgydgNGJjugYVeafoCCswoGjpw5w5CvD4= +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= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= -github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbMzMmVqPa5mO1u8vMBlybRCW2ieXpA= -github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= +github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg= +github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= -github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 h1:GNKxzBirvK9arfVRGVebhFYBp3tnOZG3nlIog6N5/6I= -github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= -github.com/djherbis/stream v1.5.1 h1:9AoCl0bnm7imWT2uUORqT8aLuTh+YllyynYpolpjIqY= -github.com/djherbis/stream v1.5.1/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= +github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc= +github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4/go.mod h1:dHWjlanKIxaHVH1xJOTb4kzP800XdcXlgJ6JYlR2DPU= +github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE= +github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= -github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +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.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= -github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= +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/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.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/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA= -github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY= +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/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= -github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +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/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.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= -github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= +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/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.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= -github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= -github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= -github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= -github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +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.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-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-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= github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg= -github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4= -github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8= +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-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY= -github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96/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= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -128,24 +125,30 @@ 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-20260724033716-83e58baca724 h1:QixF8Mcbe87ET7pK/fPbBJ9GXFddmEY8yYMepzMzo30= -github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724/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.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= -github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= +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/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= -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/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/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.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= -github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -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/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/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= @@ -156,24 +159,24 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= -github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8= -github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo= +github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q= +github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= 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.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= -github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4= -github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8= +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.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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= -github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +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/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= @@ -188,18 +191,14 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= -github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= -github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= -github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= 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.33.0 h1:C8gBA6Uc2ZEubiV+SXiu5tZnMTwEmXHgkJwGozKtZf8= -github.com/onsi/ginkgo/v2 v2.33.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.44.0 h1:eAiGl3Pw5jz5GQdDff0BcxYpAX1JxW8xD7mFUuwNfZQ= -github.com/onsi/gomega v1.44.0/go.mod h1:e/C2HwaZ1DhvjzXXuFhcR7hY7Sh9pl7MmoWKEjzwcdA= -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/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/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= @@ -208,16 +207,16 @@ 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.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc= -github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +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/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= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= -github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= @@ -225,8 +224,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.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= -github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +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/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= @@ -234,14 +233,19 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= -github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= -github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= -github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -256,6 +260,7 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -268,15 +273,14 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= 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.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= -github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 h1:6GN/lazdqr69FIzz1U6c4TF/ppE2dInMR4GzU9QKxjg= +github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633/go.mod h1:3ghOSSWYnzX0zd/3Ns4ni2tKxcXDE9/QgkwuH1PW3Rs= 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= @@ -291,70 +295,133 @@ 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= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= -go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= -go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +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= -go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= -go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= -golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= +golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +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.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +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/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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 h1:F5BWKvW126NXR74uxkxuc1jQHhm/rwm/J3rSiFyuRs4= -golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.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-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= +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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= -golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= -golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= -google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= -gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +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/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.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= -modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= +modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= 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.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= -modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= -modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +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= diff --git a/log/formatters.go b/log/formatters.go index 5d864dae4..0b27f3a43 100644 --- a/log/formatters.go +++ b/log/formatters.go @@ -26,15 +26,8 @@ func ShortDur(d time.Duration) string { default: s = d.String() } - // Drop whole zero-valued trailing components ("4h0m0s" -> "4h"). The suffix has to include - // the preceding unit, or a value that merely ends in a zero digit loses it: "10s" -> "1". - if strings.HasSuffix(s, "m0s") { - s = strings.TrimSuffix(s, "0s") - } - if strings.HasSuffix(s, "h0m") { - s = strings.TrimSuffix(s, "0m") - } - return s + s = strings.TrimSuffix(s, "0s") + return strings.TrimSuffix(s, "0m") } func StringerValue(s fmt.Stringer) string { diff --git a/log/formatters_test.go b/log/formatters_test.go index 64d72bdbd..6ed43a094 100644 --- a/log/formatters_test.go +++ b/log/formatters_test.go @@ -25,12 +25,6 @@ var _ = DescribeTable("ShortDur", Entry("4m3s", 4*time.Minute+3*time.Second, "4m3s"), Entry("4h", 4*time.Hour, "4h"), Entry("4h", 4*time.Hour+2*time.Second, "4h"), - // A trailing zero digit is significant: only a whole zero-valued component may be dropped. - Entry("zero", time.Duration(0), "0s"), - Entry("10s", 10*time.Second, "10s"), - Entry("20s", 20*time.Second, "20s"), - Entry("1m30s", time.Minute+30*time.Second, "1m30s"), - Entry("2h30m", 2*time.Hour+30*time.Minute, "2h30m"), Entry("4h2m", 4*time.Hour+2*time.Minute+5*time.Second+200*time.Millisecond, "4h2m"), ) diff --git a/log/journal.go b/log/journal.go index dd7cf5400..f1c17d2e7 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 := fmt.Appendf(nil, "<%d>", priority) + prefix := []byte(fmt.Sprintf("<%d>", priority)) return append(prefix, formatted...), nil } diff --git a/log/log.go b/log/log.go index da1d7622e..2764d80e5 100644 --- a/log/log.go +++ b/log/log.go @@ -1,7 +1,6 @@ package log import ( - "cmp" "context" "errors" "fmt" @@ -10,10 +9,9 @@ import ( "net/http" "os" "runtime" - "slices" + "sort" "strings" "sync" - "sync/atomic" "time" "github.com/sirupsen/logrus" @@ -27,16 +25,14 @@ var redacted = &Hook{ AcceptedLevels: logrus.AllLevels, RedactionList: []string{ // Keys from the config - "(ApiKey:[\\s]*\")[\\w]*", - "(Secret:[\\s]*\")[\\w]*", + "(ApiKey:\")[\\w]*", + "(Secret:\")[\\w]*", "(PasswordEncryptionKey:[\\s]*\")[^\"]*", "(UserHeader:[\\s]*\")[^\"]*", "(TrustedSources:[\\s]*\")[^\"]*", "(MetricsPath:[\\s]*\")[^\"]*", "(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*", "(DevAutoLoginUsername:[\\s]*\")[^\"]*", - // Prometheus.Password. Any character is allowed, so skip escaped quotes in the value - `(Password:[\s]*")(?:[^"\\]|\\.)*`, // UI appConfig "(subsonicToken:)[\\w]+(\\s)", @@ -49,15 +45,8 @@ var redacted = &Hook{ "([^\\w]p=)[^&]+", "([^\\w]jwt=)[^&]+", - // External services query params. Values can be JWTs (dots, dashes), so match everything up - // to the next query separator or whitespace, not just word chars. A [\w]+ class would stop - // at a JWT's first '.' and leak its payload and signature. Case-insensitive with an - // optional underscore: the API accepts api_key, apikey and ApiKey alike. - "(?i)([^\\w]api_?key=)[^&\\s]+", - - // Sensitive request headers, logged as a JSON blob at trace level and never matched by the - // query-param patterns above. Blank the whole value array; values may hold escaped quotes. - `(?i)("(?:Authorization|X-Emby-Token|X-MediaBrowser-Token|X-Nd-Authorization)":\[")[^\]]*("\])`, + // External services query params + "([^\\w]api_key=)[\\w]+", }, } @@ -80,19 +69,18 @@ type levelPath struct { } var ( - currentLevel atomic.Uint32 - hasLogLevelOverrides atomic.Bool - loggerMu sync.RWMutex - defaultLogger = logrus.New() - logSourceLine = false - rootPath string - logLevels []levelPath + currentLevel Level + loggerMu sync.RWMutex + defaultLogger = logrus.New() + logSourceLine = false + rootPath string + logLevels []levelPath ) // SetLevel sets the global log level used by the simple logger. func SetLevel(l Level) { loggerMu.Lock() - currentLevel.Store(uint32(l)) + currentLevel = l defaultLogger.Level = logrus.TraceLevel loggerMu.Unlock() logrus.SetLevel(logrus.Level(l)) @@ -131,10 +119,9 @@ func SetLogLevels(levels map[string]string) { for k, v := range levels { logLevels = append(logLevels, levelPath{path: k, level: ParseLogLevel(v)}) } - slices.SortFunc(logLevels, func(a, b levelPath) int { - return cmp.Compare(b.path, a.path) + sort.Slice(logLevels, func(i, j int) bool { + return logLevels[i].path > logLevels[j].path }) - hasLogLevelOverrides.Store(len(logLevels) != 0) } func SetLogSourceLine(enabled bool) { @@ -188,18 +175,16 @@ func NewContext(ctx context.Context, keyValuePairs ...any) context.Context { return ctx } -// 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 { +func SetDefaultLogger(l *logrus.Logger) { loggerMu.Lock() defer loggerMu.Unlock() - prev := defaultLogger defaultLogger = l - return prev } func CurrentLevel() Level { - return Level(currentLevel.Load()) + loggerMu.RLock() + defer loggerMu.RUnlock() + return currentLevel } // IsGreaterOrEqualTo returns true if the caller's current log level is equal or greater than the provided level. @@ -208,39 +193,34 @@ 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) } @@ -252,18 +232,18 @@ func Writer() io.Writer { } func shouldLog(requiredLevel Level, skip int) bool { - level := Level(currentLevel.Load()) + loggerMu.RLock() + level := currentLevel + levels := logLevels + loggerMu.RUnlock() + if level >= requiredLevel { return true } - if !hasLogLevelOverrides.Load() { + if len(levels) == 0 { return false } - loggerMu.RLock() - levels := logLevels - loggerMu.RUnlock() - _, file, _, ok := runtime.Caller(skip) if !ok { return false diff --git a/log/log_test.go b/log/log_test.go index 184ff57db..a1f3b6ba0 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -2,14 +2,11 @@ package log import ( "context" - "encoding/json" "errors" - "net/http" "net/http/httptest" "testing" "time" - "github.com/kr/pretty" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/sirupsen/logrus" @@ -95,7 +92,7 @@ var _ = Describe("Logger", func() { SetLogSourceLine(true) Error("A crash happened") // NOTE: This assertion breaks if the line number above changes - Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:96")) + Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:93")) Expect(hook.LastEntry().Message).To(Equal("A crash happened")) }) @@ -138,32 +135,18 @@ var _ = Describe("Logger", func() { }) Describe("LogLevels", func() { - BeforeEach(func() { - SetLevel(LevelFatal) - SetLogLevels(nil) - }) - - DescribeTable("logs at specific levels", func(logger func(...any), level Level) { - logger("message 1") + It("logs at specific levels", func() { + SetLevel(LevelError) + Debug("message 1") Expect(hook.LastEntry()).To(BeNil()) - Log(level, "message 1.5") - Expect(hook.LastEntry()).To(BeNil()) + SetLogLevels(map[string]string{ + "log/log_test": "debug", + }) - SetLogLevels(map[string]string{"log/log_test": "trace"}) - - logger("message 2") + Debug("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() { @@ -262,99 +245,5 @@ var _ = Describe("Logger", func() { msg := "getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=first%20and%20other%20words&title=Title" Expect(Redact(msg)).To(Equal("getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=[REDACTED]&title=Title")) }) - - It("redacts a whole JWT in api_key, not just up to its first dot", func() { - msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1" - Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1")) - }) - - DescribeTable("redacts every api_key spelling the Jellyfin API accepts", - func(param string) { - msg := "/jellyfin/Audio/abc/File?" + param + "=SECRET&other=1" - Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/File?" + param + "=[REDACTED]&other=1")) - }, - Entry("api_key", "api_key"), - Entry("apikey", "apikey"), - Entry("ApiKey", "ApiKey"), - Entry("APIKEY", "APIKEY"), - ) - - It("redacts sensitive request headers in a logged header blob", func() { - h := http.Header{ - "Authorization": {`MediaBrowser Client="Finamp", Token="jwt-secret"`}, - "X-Emby-Token": {"emby-secret"}, - "X-Mediabrowser-Token": {"mb-secret"}, - "X-Nd-Authorization": {"Bearer nd-secret"}, - "User-Agent": {"Finamp/1.0"}, - } - blob, _ := json.Marshal(h) - got := Redact(string(blob)) - Expect(got).ToNot(ContainSubstring("secret")) - Expect(got).To(ContainSubstring(`"User-Agent":["Finamp/1.0"]`)) - }) - - // https://github.com/navidrome/navidrome/discussions/6232 - DescribeTable("redacts config keys in the startup Configuration dump", - func(line, expected string) { - Expect(Redact(line)).To(Equal(expected)) - }, - Entry("unpadded ApiKey", `ApiKey:"0123456789abcdef0123456789abcdef"`, `ApiKey:"[REDACTED]"`), - Entry("unpadded Secret", `Secret:"fedcba9876543210fedcba9876543210"`, `Secret:"[REDACTED]"`), - Entry("padded ApiKey", ` ApiKey: "0123456789abcdef0123456789abcdef",`, - ` ApiKey: "[REDACTED]",`), - Entry("padded Secret", ` Secret: "fedcba9876543210fedcba9876543210",`, - ` Secret: "[REDACTED]",`), - Entry("unpadded Prometheus Password", `Password:"p@ss w0rd!"`, `Password:"[REDACTED]"`), - Entry("padded Prometheus Password", ` Password: "p@ss w0rd!",`, ` Password: "[REDACTED]",`), - Entry("Prometheus Password with escaped quotes", ` Password: "a\"b\\\"c",`, - ` Password: "[REDACTED]",`), - ) - - It("redacts secrets in a pretty-printed config struct", func() { - // Mirrors conf.lastfmOptions and conf.prometheusOptions (conf imports log, so it can't be - // used here). pretty only breaks a struct into padded lines when it is long enough, so - // keep all the fields. - type lastfmOptions struct { - Enabled bool - ApiKey string - Secret string - Language string - ScrobbleFirstArtistOnly bool - Languages []string - } - type prometheusOptions struct { - Enabled bool - MetricsPath string - Password string - } - type configOptions struct { - Address string - LastFM lastfmOptions - Prometheus prometheusOptions - } - cfg := configOptions{ - Address: "0.0.0.0", - LastFM: lastfmOptions{ //nolint:gosec - Enabled: true, - ApiKey: "0123456789abcdef0123456789abcdef", - Secret: "fedcba9876543210fedcba9876543210", - Language: "en", - Languages: []string{"en"}, - }, - Prometheus: prometheusOptions{ //nolint:gosec - Enabled: true, - MetricsPath: "/metrics", - Password: `prom"pass-tail`, - }, - } - dump := pretty.Sprintf("Configuration: %# v", cfg) - Expect(dump).To(MatchRegexp(`ApiKey:\s{2,}"`), "the dump must use the padded layout") - - got := Redact(dump) - Expect(got).ToNot(ContainSubstring(cfg.LastFM.ApiKey)) - Expect(got).ToNot(ContainSubstring(cfg.LastFM.Secret)) - Expect(got).ToNot(ContainSubstring("pass-tail")) - Expect(got).To(ContainSubstring(`"en"`)) - }) }) }) diff --git a/model/album.go b/model/album.go index a43195419..667f4695b 100644 --- a/model/album.go +++ b/model/album.go @@ -1,20 +1,19 @@ package model import ( - "context" + "fmt" "iter" "math" "sync" "time" - "github.com/deluan/rest" - "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/conf" + + "github.com/gohugoio/hashstructure" ) type Album struct { Annotations `structs:"-" hash:"ignore"` - ItemImage `structs:"-" hash:"ignore"` ID string `structs:"id" json:"id"` LibraryID int `structs:"library_id" json:"libraryId"` @@ -50,8 +49,6 @@ type Album struct { MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` - RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"` - RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` // External metadata fields Description string `structs:"description" json:"description,omitempty" hash:"ignore"` @@ -77,8 +74,8 @@ func (a Album) CoverArtID() ArtworkID { } func (a Album) FullName() string { - if v := a.Tags.First(TagAlbumVersion); conf.Server.Subsonic.AppendAlbumVersion && v != "" { - return appendSuffix(a.Name, v) + if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 { + return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0]) } return a.Name } @@ -138,25 +135,19 @@ type Albums []Album type AlbumCursor iter.Seq2[Album, error] type AlbumRepository interface { - rest.Repository[Album] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Exists(ctx context.Context, id string) (bool, error) - Put(ctx context.Context, m *Album) error - UpdateExternalInfo(ctx context.Context, m *Album) error - Get(ctx context.Context, id string) (*Album, error) - GetAll(ctx context.Context, options ...QueryOptions) (Albums, error) - // GetSoleAlbumArtistIDsInSubtrees returns the sole album artists of the albums with folders in - // any of the given library-relative subtrees. - GetSoleAlbumArtistIDsInSubtrees(ctx context.Context, lib Library, paths ...string) ([]string, error) - GetCursor(ctx context.Context, options ...QueryOptions) (AlbumCursor, error) - GetYears(ctx context.Context, libraryIDs ...int) ([]int, error) + CountAll(...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Put(*Album) error + UpdateExternalInfo(*Album) error + Get(id string) (*Album, error) + GetAll(...QueryOptions) (Albums, error) // The following methods are used exclusively by the scanner: - Touch(ctx context.Context, ids ...string) error - TouchByMissingFolder(ctx context.Context) (int64, error) - GetTouchedAlbums(ctx context.Context, libID int) (AlbumCursor, error) - RefreshPlayCounts(ctx context.Context) (int64, error) - CopyAttributes(ctx context.Context, fromID, toID string, columns ...string) error + Touch(ids ...string) error + TouchByMissingFolder() (int64, error) + GetTouchedAlbums(libID int) (AlbumCursor, error) + RefreshPlayCounts() (int64, error) + CopyAttributes(fromID, toID string, columns ...string) error AnnotatedRepository SearchableRepository[Albums] diff --git a/model/album_test.go b/model/album_test.go index ad2ca1cb6..0f4c912cd 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -24,8 +24,6 @@ var _ = Describe("Album", func() { Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"), Entry("returns just name when tag is absent", true, Tags{}, "Album"), Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), - Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Remastered)"}}, "Album (Remastered)"), - Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Remastered]"}}, "Album [Remastered]"), ) }) diff --git a/model/annotation.go b/model/annotation.go index 64b8ddc17..5228028a6 100644 --- a/model/annotation.go +++ b/model/annotation.go @@ -1,9 +1,6 @@ package model -import ( - "context" - "time" -) +import "time" type Annotations struct { PlayCount int64 `structs:"play_count" json:"playCount,omitempty"` @@ -16,8 +13,8 @@ type Annotations struct { } type AnnotatedRepository interface { - IncPlayCount(ctx context.Context, itemID string, ts time.Time) error - SetStar(ctx context.Context, starred bool, itemIDs ...string) error - SetRating(ctx context.Context, rating int, itemID string) error - ReassignAnnotation(ctx context.Context, prevID string, newID string) error + IncPlayCount(itemID string, ts time.Time) error + SetStar(starred bool, itemIDs ...string) error + SetRating(rating int, itemID string) error + ReassignAnnotation(prevID string, newID string) error } diff --git a/model/artist.go b/model/artist.go index 985cc6c7b..2085f0051 100644 --- a/model/artist.go +++ b/model/artist.go @@ -1,19 +1,15 @@ package model import ( - "context" - "iter" "maps" "slices" "time" - "github.com/deluan/rest" "github.com/navidrome/navidrome/consts" ) type Artist struct { Annotations `structs:"-"` - ItemImage `structs:"-"` ID string `structs:"id" json:"id"` @@ -83,22 +79,18 @@ type ArtistIndex struct { } type ArtistIndexes []ArtistIndex -type ArtistCursor iter.Seq2[Artist, error] - type ArtistRepository interface { - rest.Repository[Artist] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Exists(ctx context.Context, id string) (bool, error) - Put(ctx context.Context, m *Artist, colsToUpdate ...string) error - UpdateExternalInfo(ctx context.Context, a *Artist) error - Get(ctx context.Context, id string) (*Artist, error) - GetAll(ctx context.Context, options ...QueryOptions) (Artists, error) - GetCursor(ctx context.Context, options ...QueryOptions) (ArtistCursor, error) - GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) + CountAll(options ...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Put(m *Artist, colsToUpdate ...string) error + UpdateExternalInfo(a *Artist) error + Get(id string) (*Artist, error) + GetAll(options ...QueryOptions) (Artists, error) + GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) // The following methods are used exclusively by the scanner: - RefreshPlayCounts(ctx context.Context) (int64, error) - RefreshStats(ctx context.Context, allArtists bool) (int64, error) + RefreshPlayCounts() (int64, error) + RefreshStats(allArtists bool) (int64, error) AnnotatedRepository SearchableRepository[Artists] diff --git a/model/artist_test.go b/model/artist_test.go index db897d3d5..5a24504eb 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 = conf.NewDir("/data") + conf.Server.DataFolder = "/data" }) It("returns empty string when no image uploaded", func() { diff --git a/model/artwork.go b/model/artwork.go deleted file mode 100644 index d5f62ab13..000000000 --- a/model/artwork.go +++ /dev/null @@ -1,182 +0,0 @@ -package model - -import ( - "context" - "time" -) - -// Artwork is one unique image, identified by the XXH3-64 hash of its bytes. -type Artwork struct { - Hash string `structs:"hash"` - Mime string `structs:"mime"` - Width int `structs:"width"` - Height int `structs:"height"` - SizeBytes int64 `structs:"size_bytes"` - BlurHash string `structs:"blur_hash"` - ThumbHash string `structs:"thumb_hash"` - // DominantColor is "#rrggbb": a flat placeholder clients can paint before any decode. - DominantColor string `structs:"dominant_color"` - CreatedAt time.Time `structs:"created_at"` -} - -const ImageTypePrimary = "primary" - -// ArtworkSourceFailed is a pseudo-source selecting absent states that exhausted the retry budget -// rather than being answered. The "!" keeps it from colliding with a stored source value. -const ArtworkSourceFailed = "!failed" - -// ItemImage is per-entity artwork state hydrated at query time; never persisted. -type ItemImage struct { - ImageHash string `structs:"-" json:"imageHash,omitempty"` - ImageAbsent bool `structs:"-" json:"imageAbsent,omitempty"` - // BlurHash is Jellyfin's; its mappers read this field directly, so it stays off native JSON. - BlurHash string `structs:"-" json:"-"` - ThumbHash string `structs:"-" json:"thumbHash,omitempty"` - // DominantColor is the only placeholder needing no decode, so it can paint on the first frame. - DominantColor string `structs:"-" json:"dominantColor,omitempty"` - // A thumbhash's own aspect is quantised, so clients need these to shape the placeholder exactly. - ImageWidth int `structs:"-" json:"imageWidth,omitempty"` - ImageHeight int `structs:"-" json:"imageHeight,omitempty"` -} - -// AspectRatio is the image's width/height, or nil when the image or its dimensions are unknown. -func (i ItemImage) AspectRatio() *float64 { - if i.ImageAbsent || i.ImageWidth <= 0 || i.ImageHeight <= 0 { - return nil - } - return new(float64(i.ImageWidth) / float64(i.ImageHeight)) -} - -// ItemArtwork is an entity's resolved artwork state. Hash=="" means known absent. -type ItemArtwork struct { - ItemKind string `structs:"item_kind"` - ItemID string `structs:"item_id"` - ImageType string `structs:"image_type"` - Hash string `structs:"hash"` - Source string `structs:"source"` - // SourcePath is the backing file (folder/upload: the image; embedded: the audio file); "" otherwise. - SourcePath string `structs:"source_path"` - // RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath. - RefMtime int64 `structs:"ref_mtime"` - // Trace is the encoded walk that produced this state; LastFailure is the walk of the attempt - // that exhausted the retry budget. Both are JSON, read back with artwork.DecodeTrace. - Trace string `structs:"trace"` - LastFailure string `structs:"last_failure"` - // Nullable in the schema, but every insert must set them: these non-pointer fields cannot scan NULL. - AttemptedAt time.Time `structs:"attempted_at"` - UpdatedAt time.Time `structs:"updated_at"` -} - -// ItemArtworkInfo is the list-hydration projection (item_artwork joined with artwork). -type ItemArtworkInfo struct { - ItemID string - Hash string - BlurHash string - ThumbHash string - DominantColor string - Width int - Height int -} - -// Absent reports a known-absent artwork state (resolved, no image). -func (i ItemArtworkInfo) Absent() bool { return i.Hash == "" } - -// Image projects the hydration entry onto the entity-facing struct. -func (i ItemArtworkInfo) Image() ItemImage { - return ItemImage{ - ImageHash: i.Hash, - ImageAbsent: i.Absent(), - BlurHash: i.BlurHash, - ThumbHash: i.ThumbHash, - DominantColor: i.DominantColor, - ImageWidth: i.Width, - ImageHeight: i.Height, - } -} - -type ArtworkQueueItem struct { - ItemKind string `structs:"item_kind"` - ItemID string `structs:"item_id"` - ImageType string `structs:"image_type"` - Priority int `structs:"priority"` - Attempts int `structs:"attempts"` - // RetryAt is the earliest time the drain may take this row, not when it will run. - RetryAt time.Time `structs:"retry_at"` - EnqueuedAt time.Time `structs:"enqueued_at"` - // Trace is why the last attempt failed. Only Get reads it; the drain projects it away. - Trace string `structs:"trace"` -} - -// Queue priorities: higher drains first. -const ( - ArtworkPriorityRecheck = 0 - // ArtworkPriorityBackfill sits between the hourly sweep and scan-driven work. Nothing enqueues - // it today; it stays named so a row still carrying it can be reported and cancelled. - ArtworkPriorityBackfill = 10 - ArtworkPriorityScan = 50 - ArtworkPriorityBump = 100 -) - -// Delete* takes the rows to remove; Purge* finds them itself and reports how many went. -type ArtworkRepository interface { - GetImage(ctx context.Context, hash string) (*Artwork, error) - PutImage(ctx context.Context, a *Artwork) error - // PurgeOrphans deletes rows referenced by no item_artwork row and older than cutoff. - PurgeOrphans(ctx context.Context, createdBefore time.Time) (int64, error) - GetItemArtwork(ctx context.Context, kind Kind, id, imageType string) (*ItemArtwork, error) - PutItemArtwork(ctx context.Context, ia *ItemArtwork) error - // PutLastFailure records the trace of the attempt that exhausted the retry budget. - PutLastFailure(ctx context.Context, kind Kind, id, imageType, trace string) error - DeleteForItems(ctx context.Context, kind Kind, ids []string) error - // GetInfoForItems hydrates a page in one batched query. - GetInfoForItems(ctx context.Context, kind Kind, ids []string) (map[string]ItemArtworkInfo, error) - // GetMimeByHash returns hash -> current mime for every stored artwork. - GetMimeByHash(ctx context.Context) (map[string]string, error) - // PurgeDanglingItems removes state rows whose entity no longer exists. - PurgeDanglingItems(ctx context.Context) (int64, error) -} - -type ArtworkQueueRepository interface { - // Get returns the pending row for an item, or ErrNotFound when it is not queued. - Get(ctx context.Context, kind Kind, id, imageType string) (*ArtworkQueueItem, error) - // Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset. - Enqueue(ctx context.Context, items ...ArtworkQueueItem) error - // EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a - // request-triggered read-through never resets a failed resolution's backoff. - EnqueuePreservingBackoff(ctx context.Context, items ...ArtworkQueueItem) error - // EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority. - EnqueueAllMissing(ctx context.Context, kind Kind, priority int) (int64, error) - // EnqueueIfMissing inserts only for items with no item_artwork row yet. - EnqueueIfMissing(ctx context.Context, items ...ArtworkQueueItem) error - // CountBySource reports how many items of a kind currently resolve from the given sources. - // An empty sources slice means every source; "" matches absent state, and the pseudo-source - // ArtworkSourceFailed matches the absent states that gave up. - CountBySource(ctx context.Context, kind Kind, sources []string) (int64, error) - // SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included. - SourcesInUse(ctx context.Context, kind Kind) ([]string, error) - // EnqueueBySource inserts queue rows for items of a kind whose current source matches. - // It does not clear existing artwork state: the current image stays until it is replaced. - EnqueueBySource(ctx context.Context, kind Kind, sources []string, priority int) (int64, error) - // DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc. - // Restricted to the given kinds when any are passed, so one kind cannot block another's drain. - DequeueBatch(ctx context.Context, n int, kinds ...string) ([]ArtworkQueueItem, error) - // MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches - // seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility. - MarkFailedIfUnchanged(ctx context.Context, kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error - // DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue. - DeleteIfUnchanged(ctx context.Context, kind, id, imageType string, retryAt time.Time) error - Count(ctx context.Context) (int64, error) - // CountQueued reports the pending rows matching the kinds and priorities, grouped by both; - // an empty filter means every one. - CountQueued(ctx context.Context, kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) - // PurgeDangling removes queue rows whose entity no longer exists. - PurgeDangling(ctx context.Context) (int64, error) - // PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one. - PurgeQueued(ctx context.Context, kinds []Kind, priorities []int) (int64, error) -} - -type ArtworkQueueStat struct { - ItemKind string - Priority int - Count int64 -} diff --git a/model/artwork_id.go b/model/artwork_id.go index e827e935a..1bd146c1f 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -6,8 +6,6 @@ import ( "strconv" "strings" "time" - - "github.com/navidrome/navidrome/utils/slice" ) type Kind struct { @@ -19,11 +17,6 @@ func (k Kind) String() string { return k.name } -// Prefix is the short token used in artwork ids and the item_artwork.item_kind column. -func (k Kind) Prefix() string { - return k.prefix -} - var ( KindMediaFileArtwork = Kind{"mf", "media_file"} KindArtistArtwork = Kind{"ar", "artist"} @@ -42,23 +35,10 @@ var artworkKindMap = map[string]Kind{ KindRadioArtwork.prefix: KindRadioArtwork, } -// KindPrefixes leaves the typed Kind domain for the item_kind column, or for a help string. -func KindPrefixes(kinds []Kind) []string { - return slice.Map(kinds, func(k Kind) string { return k.prefix }) -} - -// ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known. -// Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain. -func ParseKind(prefix string) (Kind, bool) { - k, ok := artworkKindMap[prefix] - return k, ok -} - type ArtworkID struct { Kind Kind ID string - Hash string // content-hash suffix; "" = unknown/none - LastUpdate time.Time // legacy: populated only when parsing old _ tokens + LastUpdate time.Time } func (id ArtworkID) String() string { @@ -66,14 +46,14 @@ func (id ArtworkID) String() string { return "" } s := fmt.Sprintf("%s-%s", id.Kind.prefix, id.ID) - if id.Hash != "" { - return s + "_" + id.Hash + if lu := id.LastUpdate.Unix(); lu > 0 { + return fmt.Sprintf("%s_%x", s, lu) } - return s + return s + "_0" } func NewArtworkID(kind Kind, id string, lastUpdate *time.Time) ArtworkID { - artID := ArtworkID{Kind: kind, ID: id} + artID := ArtworkID{kind, id, time.Time{}} if lastUpdate != nil { artID.LastUpdate = *lastUpdate } @@ -95,34 +75,18 @@ func ParseArtworkID(id string) (ArtworkID, error) { } parts = strings.SplitN(parts[1], "_", 2) if len(parts) == 2 { - parsedID.ID = parts[0] - suffix := parts[1] - switch { - // Hash detection must come first: a 16-hex value with the high bit set overflows int64. - case isImageHash(suffix): - parsedID.Hash = suffix - case suffix != "0": - if lastUpdate, err := strconv.ParseInt(suffix, 16, 64); err == nil { - parsedID.LastUpdate = time.Unix(lastUpdate, 0) + if parts[1] != "0" { + lastUpdate, err := strconv.ParseInt(parts[1], 16, 64) + if err != nil { + return ArtworkID{}, err } + parsedID.LastUpdate = time.Unix(lastUpdate, 0) } + parsedID.ID = parts[0] } return parsedID, nil } -// isImageHash reports whether s is a 16-char lowercase-hex XXH3-64 content hash. -func isImageHash(s string) bool { - if len(s) != 16 { - return false - } - for _, c := range s { - if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { - return false - } - } - return true -} - func MustParseArtworkID(id string) ArtworkID { artID, err := ParseArtworkID(id) if err != nil { @@ -148,21 +112,40 @@ func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) { } func artworkIDFromAlbum(al Album) ArtworkID { - return ArtworkID{Kind: KindAlbumArtwork, ID: al.ID, Hash: al.ImageHash} + return ArtworkID{ + Kind: KindAlbumArtwork, + ID: al.ID, + LastUpdate: al.UpdatedAt, + } } func artworkIDFromMediaFile(mf MediaFile) ArtworkID { - return ArtworkID{Kind: KindMediaFileArtwork, ID: mf.ID, Hash: mf.ImageHash} + return ArtworkID{ + Kind: KindMediaFileArtwork, + ID: mf.ID, + LastUpdate: mf.UpdatedAt, + } } func artworkIDFromPlaylist(pls Playlist) ArtworkID { - return ArtworkID{Kind: KindPlaylistArtwork, ID: pls.ID, Hash: pls.ImageHash} + return ArtworkID{ + Kind: KindPlaylistArtwork, + ID: pls.ID, + LastUpdate: pls.UpdatedAt, + } } func artworkIDFromArtist(ar Artist) ArtworkID { - return ArtworkID{Kind: KindArtistArtwork, ID: ar.ID, Hash: ar.ImageHash} + return ArtworkID{ + Kind: KindArtistArtwork, + ID: ar.ID, + } } func artworkIDFromRadio(r Radio) ArtworkID { - return ArtworkID{Kind: KindRadioArtwork, ID: r.ID, Hash: r.ImageHash} + return ArtworkID{ + Kind: KindRadioArtwork, + ID: r.ID, + LastUpdate: r.UpdatedAt, + } } diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index af6a12ffb..b634e7cbc 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -9,31 +9,15 @@ import ( ) var _ = Describe("ArtworkID", func() { - Describe("String()", func() { - It("returns a bare id when there is no hash", func() { - id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234"} - Expect(id.String()).To(Equal("al-1234")) - }) - It("appends the hash suffix when set", func() { - id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234", Hash: "abcdef0123456789"} - Expect(id.String()).To(Equal("al-1234_abcdef0123456789")) - }) - It("never emits a legacy timestamp/_0 suffix", func() { - id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) - Expect(id.String()).To(Equal("al-1234")) - }) - It("returns empty string for an empty id", func() { - Expect(model.ArtworkID{Kind: model.KindAlbumArtwork}.String()).To(BeEmpty()) - }) - }) - Describe("NewArtworkID()", func() { - It("round-trips Kind and ID through String()", func() { - id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) + It("creates a valid parseable ArtworkID", func() { + now := time.Now() + id := model.NewArtworkID(model.KindAlbumArtwork, "1234", &now) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) + Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) It("creates a valid ArtworkID without lastUpdate info", func() { id := model.NewArtworkID(model.KindPlaylistArtwork, "1234", nil) @@ -41,16 +25,18 @@ var _ = Describe("ArtworkID", func() { Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) + Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) }) - Describe("ParseArtworkID - disc kind", func() { It("parses a disc artwork ID with dc prefix", func() { - id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", nil) + now := time.Now() + id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", &now) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(model.KindDiscArtwork)) Expect(parsedId.ID).To(Equal("albumid123:2")) + Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix())) }) }) @@ -82,7 +68,6 @@ var _ = Describe("ArtworkID", func() { Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindAlbumArtwork)) Expect(id.ID).To(Equal("1234")) - Expect(id.Hash).To(BeEmpty()) }) It("parses media file artwork ids", func() { id, err := model.ParseArtworkID("mf-a6f8d2b1") @@ -90,45 +75,12 @@ var _ = Describe("ArtworkID", func() { Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) Expect(id.ID).To(Equal("a6f8d2b1")) }) - It("parses playlist artwork ids with dashed UUID", func() { + It("parses playlists artwork ids", func() { id, err := model.ParseArtworkID("pl-18690de0-151b-4d86-81cb-f418a907315a") Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindPlaylistArtwork)) Expect(id.ID).To(Equal("18690de0-151b-4d86-81cb-f418a907315a")) }) - It("captures a 16-hex suffix as Hash", func() { - id, err := model.ParseArtworkID("al-1234_abcdef0123456789") - Expect(err).ToNot(HaveOccurred()) - Expect(id.ID).To(Equal("1234")) - Expect(id.Hash).To(Equal("abcdef0123456789")) - Expect(id.LastUpdate.IsZero()).To(BeTrue()) - }) - It("captures a high-bit 16-hex suffix as Hash without error", func() { - id, err := model.ParseArtworkID("al-1234_ffffffffffffffff") - Expect(err).ToNot(HaveOccurred()) - Expect(id.ID).To(Equal("1234")) - Expect(id.Hash).To(Equal("ffffffffffffffff")) - }) - It("parses a legacy hex-timestamp suffix as LastUpdate", func() { - id, err := model.ParseArtworkID("al-123_688a1b2c") - Expect(err).ToNot(HaveOccurred()) - Expect(id.ID).To(Equal("123")) - Expect(id.Hash).To(BeEmpty()) - Expect(id.LastUpdate.Unix()).To(Equal(int64(0x688a1b2c))) - }) - It("parses a legacy _0 suffix", func() { - id, err := model.ParseArtworkID("al-123_0") - Expect(err).ToNot(HaveOccurred()) - Expect(id.ID).To(Equal("123")) - Expect(id.Hash).To(BeEmpty()) - Expect(id.LastUpdate.IsZero()).To(BeTrue()) - }) - It("silently drops a garbage suffix", func() { - id, err := model.ParseArtworkID("al-123_zz") - Expect(err).ToNot(HaveOccurred()) - Expect(id.ID).To(Equal("123")) - Expect(id.Hash).To(BeEmpty()) - }) It("fails to parse malformed ids", func() { _, err := model.ParseArtworkID("a6f8d2b1") Expect(err).To(MatchError("invalid artwork id")) diff --git a/model/artwork_test.go b/model/artwork_test.go deleted file mode 100644 index baf741e68..000000000 --- a/model/artwork_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package model_test - -import ( - "encoding/json" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func jsonOf(v any) map[string]any { - GinkgoHelper() - data, err := json.Marshal(v) - Expect(err).ToNot(HaveOccurred()) - var out map[string]any - Expect(json.Unmarshal(data, &out)).To(Succeed()) - return out -} - -var _ = Describe("ItemImage JSON", func() { - It("exposes the artwork state a client needs to render a placeholder", func() { - al := model.Album{ID: "al-1", Name: "Album"} - al.ImageHash = "0123456789abcdef" - al.ThumbHash = "1QcSHQRn" - al.ImageWidth, al.ImageHeight = 1200, 800 - - Expect(jsonOf(al)).To(SatisfyAll( - HaveKeyWithValue("imageHash", "0123456789abcdef"), - HaveKeyWithValue("thumbHash", "1QcSHQRn"), - HaveKeyWithValue("imageWidth", BeNumerically("==", 1200)), - HaveKeyWithValue("imageHeight", BeNumerically("==", 800)), - )) - }) - - It("keeps the blurhash off the native API, where nothing consumes it", func() { - al := model.Album{ID: "al-1", Name: "Album"} - al.BlurHash = "LEHV6nWB2yk8" - Expect(jsonOf(al)).ToNot(HaveKey("blurHash")) - }) - - It("omits every artwork field when the entity has none", func() { - out := jsonOf(model.Album{ID: "al-2", Name: "Album"}) - for _, key := range []string{ - "imageHash", "blurHash", "thumbHash", "imageAbsent", "imageWidth", "imageHeight", - } { - Expect(out).ToNot(HaveKey(key)) - } - }) - - It("exposes known-absent artwork so clients can skip the request", func() { - ar := model.Artist{ID: "ar-1", Name: "Artist"} - ar.ImageAbsent = true - Expect(jsonOf(ar)).To(HaveKeyWithValue("imageAbsent", true)) - }) - - Describe("AspectRatio", func() { - It("returns width/height", func() { - img := model.ItemImage{ImageHash: "abc", ImageWidth: 1200, ImageHeight: 800} - Expect(*img.AspectRatio()).To(BeNumerically("~", 1.5, 0.0001)) - }) - - It("returns nil when a dimension is missing, so callers never guess a ratio", func() { - Expect(model.ItemImage{ImageHash: "abc"}.AspectRatio()).To(BeNil()) - Expect(model.ItemImage{ImageHash: "abc", ImageWidth: 1200}.AspectRatio()).To(BeNil()) - Expect(model.ItemImage{ImageHash: "abc", ImageHeight: 800}.AspectRatio()).To(BeNil()) - }) - - It("returns nil for a known-absent image, whatever the dimensions say", func() { - img := model.ItemImage{ImageAbsent: true, ImageWidth: 1200, ImageHeight: 800} - Expect(img.AspectRatio()).To(BeNil()) - }) - }) -}) diff --git a/model/bookmark.go b/model/bookmark.go index 768d4a462..7c6637ce9 100644 --- a/model/bookmark.go +++ b/model/bookmark.go @@ -1,18 +1,15 @@ package model -import ( - "context" - "time" -) +import "time" type Bookmarkable struct { BookmarkPosition int64 `structs:"-" json:"bookmarkPosition"` } type BookmarkableRepository interface { - AddBookmark(ctx context.Context, id, comment string, position int64) error - DeleteBookmark(ctx context.Context, id string) error - GetBookmarks(ctx context.Context) (Bookmarks, error) + AddBookmark(id, comment string, position int64) error + DeleteBookmark(id string) error + GetBookmarks() (Bookmarks, error) } type Bookmark struct { diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index e6db3b481..31d208d08 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -4,12 +4,9 @@ package criteria import ( "encoding/json" "errors" - "fmt" "slices" - "time" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils" ) type Expression interface { @@ -23,7 +20,6 @@ type Criteria struct { Limit int LimitPercent int Offset int - RefreshDelay time.Duration // 0 = use conf.Server.SmartPlaylistRefreshDelay } // EffectiveLimit resolves the effective limit for a query. If a fixed Limit is @@ -64,21 +60,18 @@ func (c Criteria) IsPercentageLimit() bool { } func (c Criteria) ChildPlaylistIds() []string { - return c.childPlaylistRefs(conjunction.ChildPlaylistIds) -} + if c.Expression == nil { + return nil + } -func (c Criteria) ChildPlaylistPaths() []string { - return c.childPlaylistRefs(conjunction.ChildPlaylistPaths) -} - -func (c Criteria) childPlaylistRefs(extract func(conjunction) []string) []string { parent, ok := c.Expression.(conjunction) if !ok { return nil } - refs := extract(parent) - slices.Sort(refs) - return slices.Compact(refs) + + ids := parent.ChildPlaylistIds() + slices.Sort(ids) + return slices.Compact(ids) } func (c Criteria) MarshalJSON() ([]byte, error) { @@ -90,7 +83,6 @@ func (c Criteria) MarshalJSON() ([]byte, error) { Limit int `json:"limit,omitempty"` LimitPercent int `json:"limitPercent,omitempty"` Offset int `json:"offset,omitempty"` - RefreshDelay string `json:"refreshDelay,omitempty"` }{ Sort: c.Sort, Order: c.Order, @@ -98,9 +90,6 @@ func (c Criteria) MarshalJSON() ([]byte, error) { LimitPercent: c.LimitPercent, Offset: c.Offset, } - if c.RefreshDelay > 0 { - aux.RefreshDelay = utils.FormatDuration(c.RefreshDelay) - } switch rules := c.Expression.(type) { case Any: aux.Any = rules @@ -114,27 +103,21 @@ func (c Criteria) MarshalJSON() ([]byte, error) { func (c *Criteria) UnmarshalJSON(data []byte) error { var aux struct { - All optionalConjunction `json:"all"` - Any optionalConjunction `json:"any"` - Sort string `json:"sort"` - Order string `json:"order"` - Limit int `json:"limit"` - LimitPercent int `json:"limitPercent"` - Offset int `json:"offset"` - RefreshDelay string `json:"refreshDelay"` + 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"` } if err := json.Unmarshal(data, &aux); err != nil { return err } - // 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) + if len(aux.Any) > 0 { + c.Expression = Any(aux.Any) + } else if len(aux.All) > 0 { + c.Expression = All(aux.All) } else { return errors.New("invalid criteria json. missing rules (key 'all' or 'any')") } @@ -143,14 +126,6 @@ func (c *Criteria) UnmarshalJSON(data []byte) error { c.Limit = aux.Limit c.Offset = aux.Offset - if aux.RefreshDelay != "" { - d, err := utils.ParseDuration(aux.RefreshDelay) - if err != nil { - return fmt.Errorf("invalid refreshDelay: %w", err) - } - c.RefreshDelay = d - } - // Clamp LimitPercent to [0, 100] if aux.LimitPercent < 0 { log.Warn("limitPercent value out of range, clamping to 0", "value", aux.LimitPercent) diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index de5124568..092cfd36a 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -3,7 +3,6 @@ package criteria import ( "bytes" "encoding/json" - "time" "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" @@ -81,28 +80,6 @@ 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() { @@ -256,90 +233,21 @@ var _ = Describe("Criteria", func() { }) }) - Describe("refreshDelay", func() { - newCriteria := func(extra string) []byte { - return []byte(`{"all":[{"is":{"loved":true}}]` + extra + `}`) - } - - It("unmarshals a valid refreshDelay", func() { - var c Criteria - gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1d"`), &c)).To(gomega.Succeed()) - gomega.Expect(c.RefreshDelay).To(gomega.Equal(24 * time.Hour)) - }) - - It("supports week units", func() { - var c Criteria - gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1w"`), &c)).To(gomega.Succeed()) - gomega.Expect(c.RefreshDelay).To(gomega.Equal(7 * 24 * time.Hour)) - }) - - It("leaves RefreshDelay zero when absent", func() { - var c Criteria - gomega.Expect(json.Unmarshal(newCriteria(``), &c)).To(gomega.Succeed()) - gomega.Expect(c.RefreshDelay).To(gomega.BeZero()) - }) - - It("rejects an invalid refreshDelay", func() { - var c Criteria - err := json.Unmarshal(newCriteria(`,"refreshDelay":"tomorrow"`), &c) - gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) - }) - - It("rejects a negative refreshDelay", func() { - var c Criteria - err := json.Unmarshal(newCriteria(`,"refreshDelay":"-1h"`), &c) - gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) - }) - - It("marshals RefreshDelay back as a duration string", func() { - c := Criteria{ - Expression: All{Is{"loved": true}}, - RefreshDelay: 24 * time.Hour, - } - j, err := json.Marshal(c) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(string(j)).To(gomega.ContainSubstring(`"refreshDelay":"1d"`)) - }) - - It("omits refreshDelay from JSON when zero", func() { - c := Criteria{Expression: All{Is{"loved": true}}} - j, err := json.Marshal(c) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(string(j)).ToNot(gomega.ContainSubstring("refreshDelay")) - }) - - It("round-trips through marshal and unmarshal", func() { - c := Criteria{ - Expression: All{Is{"loved": true}}, - RefreshDelay: 36 * time.Hour, - } - j, err := json.Marshal(c) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - var c2 Criteria - gomega.Expect(json.Unmarshal(j, &c2)).To(gomega.Succeed()) - gomega.Expect(c2.RefreshDelay).To(gomega.Equal(36 * time.Hour)) - }) - }) - Context("with child playlists", func() { var ( - topLevelInPlaylistID string - topLevelInPlaylistPath string - topLevelNotInPlaylistID string - nestedAnyInPlaylistID string - nestedAnyNotInPlaylistID string - nestedAllInPlaylistID string - nestedAllNotInPlaylistID string - nestedAnyNotInPlaylistPath string + topLevelInPlaylistID string + topLevelNotInPlaylistID string + nestedAnyInPlaylistID string + nestedAnyNotInPlaylistID string + nestedAllInPlaylistID string + nestedAllNotInPlaylistID string ) BeforeEach(func() { topLevelInPlaylistID = uuid.NewString() - topLevelInPlaylistPath = "./test.nsp" topLevelNotInPlaylistID = uuid.NewString() nestedAnyInPlaylistID = uuid.NewString() nestedAnyNotInPlaylistID = uuid.NewString() - nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u" nestedAllInPlaylistID = uuid.NewString() nestedAllNotInPlaylistID = uuid.NewString() @@ -347,12 +255,10 @@ var _ = Describe("Criteria", func() { goObj = Criteria{ Expression: All{ InPlaylist{"id": topLevelInPlaylistID}, - InPlaylist{"path": topLevelInPlaylistPath}, NotInPlaylist{"id": topLevelNotInPlaylistID}, Any{ InPlaylist{"id": nestedAnyInPlaylistID}, NotInPlaylist{"id": nestedAnyNotInPlaylistID}, - NotInPlaylist{"path": nestedAnyNotInPlaylistPath}, }, All{ InPlaylist{"id": nestedAllInPlaylistID}, @@ -365,18 +271,6 @@ var _ = Describe("Criteria", func() { ids := goObj.ChildPlaylistIds() gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID)) }) - It("extracts all child smart playlist paths from expression criteria", func() { - paths := goObj.ChildPlaylistPaths() - gomega.Expect(paths).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath)) - }) - It("ignores empty child playlist paths", func() { - c := Criteria{Expression: All{InPlaylist{"path": ""}, NotInPlaylist{"path": ""}}} - gomega.Expect(c.ChildPlaylistPaths()).To(gomega.BeEmpty()) - }) - It("ignores empty child playlist ids", func() { - c := Criteria{Expression: All{InPlaylist{"id": ""}, NotInPlaylist{"id": ""}}} - gomega.Expect(c.ChildPlaylistIds()).To(gomega.BeEmpty()) - }) It("extracts child smart playlist IDs from deeply nested expression", func() { goObj = Criteria{ Expression: Any{ diff --git a/model/criteria/export_test.go b/model/criteria/export_test.go index e2109aa1a..9f3f3922b 100644 --- a/model/criteria/export_test.go +++ b/model/criteria/export_test.go @@ -1,3 +1,5 @@ package criteria +var StartOfPeriod = startOfPeriod + type UnmarshalConjunctionType = unmarshalConjunctionType diff --git a/model/criteria/fields.go b/model/criteria/fields.go index a9a634c01..9eafff7ab 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -9,11 +9,8 @@ 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.yaml that resolves to this field + tagAlias string // If set, a tag name from mappings.yml that resolves to this field name string // Canonical name, populated by LookupField from the map key } @@ -24,7 +21,7 @@ func (f FieldInfo) Name() string { var fieldMap = map[string]FieldInfo{ "title": {}, - "album": {Nullable: true}, + "album": {}, "hascoverart": {Boolean: true}, "tracknumber": {}, "discnumber": {}, @@ -37,26 +34,26 @@ var fieldMap = map[string]FieldInfo{ "size": {}, "compilation": {Boolean: true}, "missing": {Boolean: true}, - "explicitstatus": {Nullable: true}, + "explicitstatus": {}, "dateadded": {}, "datemodified": {}, - "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}, + "discsubtitle": {}, + "comment": {}, + "lyrics": {}, + "sorttitle": {}, + "sortalbum": {}, + "sortartist": {}, + "sortalbumartist": {}, + "albumcomment": {}, + "catalognumber": {}, "filepath": {}, "filetype": {}, "codec": {}, "duration": {}, "bitrate": {}, - "bitdepth": {Numeric: true, Nullable: true}, + "bitdepth": {}, "samplerate": {}, - "bpm": {Numeric: true, Nullable: true}, + "bpm": {}, "channels": {}, "loved": {Boolean: true}, "dateloved": {}, @@ -71,40 +68,27 @@ var fieldMap = map[string]FieldInfo{ "albumlastplayed": {}, "albumdateloved": {}, "albumdaterated": {}, - "albumdateadded": {}, - "albumdatemodified": {}, - "albumduration": {}, - "albumsongcount": {}, - "albumsize": {}, "artistrating": {}, "artistloved": {Boolean: true}, "artistplaycount": {}, "artistlastplayed": {}, "artistdateloved": {}, "artistdaterated": {}, - "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}, + "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}, "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": {}, } @@ -144,7 +128,7 @@ func AddRoles(roles []string) { } } -// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yaml` +// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml` // 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 2367101a8..5b6f53341 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -53,39 +53,5 @@ 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 d0f453524..ca47ceb95 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -33,20 +33,6 @@ 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) @@ -110,31 +96,19 @@ func normalizeBoolFields(m map[string]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: - b, err := strconv.ParseBool(val) - return b, err == nil - case float64: - 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 + switch val := v.(type) { + case string: + if b, err := strconv.ParseBool(val); err == nil { + return b + } + case float64: + if val == 1 { + return true + } + if val == 0 { + return false + } } return v } diff --git a/model/criteria/operators.go b/model/criteria/operators.go index ec32f2d16..3ddd77f8b 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,9 +1,10 @@ package criteria -// Conjunctions need to implement this interface, to allow Criteria to extract child playlist references recursively +import "time" + +// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { ChildPlaylistIds() []string - ChildPlaylistPaths() []string } type ( @@ -17,9 +18,9 @@ func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) } -func (all All) ChildPlaylistIds() []string { return extractPlaylistField(all, "id") } - -func (all All) ChildPlaylistPaths() []string { return extractPlaylistField(all, "path") } +func (all All) ChildPlaylistIds() (ids []string) { + return extractPlaylistIds(all) +} type ( Any []Expression @@ -32,9 +33,9 @@ func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) } -func (any Any) ChildPlaylistIds() []string { return extractPlaylistField(any, "id") } - -func (any Any) ChildPlaylistPaths() []string { return extractPlaylistField(any, "path") } +func (any Any) ChildPlaylistIds() (ids []string) { + return extractPlaylistIds(any) +} type Is map[string]any type Eq = Is @@ -141,6 +142,10 @@ 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) { @@ -173,20 +178,28 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) { func (ip IsPresent) fields() map[string]any { return ip } -func extractPlaylistField(inputRule any, field string) (values []string) { +func extractPlaylistIds(inputRule any) (ids []string) { + var id string + var ok bool + switch rule := inputRule.(type) { case Any: for _, rules := range rule { - values = append(values, extractPlaylistField(rules, field)...) + ids = append(ids, extractPlaylistIds(rules)...) } case All: for _, rules := range rule { - values = append(values, extractPlaylistField(rules, field)...) + ids = append(ids, extractPlaylistIds(rules)...) } - case InPlaylist, NotInPlaylist: - if value, ok := rule.(Expression).fields()[field].(string); ok && value != "" { - values = append(values, value) + case InPlaylist: + if id, ok = rule["id"].(string); ok { + ids = append(ids, id) + } + case NotInPlaylist: + if id, ok = rule["id"].(string); ok { + ids = append(ids, id) } } + return } diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index e5c8e1763..17c4272ba 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -126,25 +126,4 @@ 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/datastore.go b/model/datastore.go index 6ded8c575..94c3c3622 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -4,6 +4,7 @@ import ( "context" "github.com/Masterminds/squirrel" + "github.com/deluan/rest" ) type QueryOptions struct { @@ -15,33 +16,34 @@ type QueryOptions struct { Seed string // for random sorting } +type ResourceRepository interface { + rest.Repository +} + type DataStore interface { - Library() LibraryRepository - Folder() FolderRepository - Album() AlbumRepository - Artist() ArtistRepository - MediaFile() MediaFileRepository - Genre() GenreRepository - Tag() TagRepository - Playlist() PlaylistRepository - PlayQueue() PlayQueueRepository - Transcoding() TranscodingRepository - Player() PlayerRepository - Radio() RadioRepository - Share() ShareRepository - Property() PropertyRepository - User() UserRepository - UserProps() UserPropsRepository - ScrobbleBuffer() ScrobbleBufferRepository - Scrobble() ScrobbleRepository - Plugin() PluginRepository - Artwork() ArtworkRepository - ArtworkQueue() ArtworkQueueRepository + Library(ctx context.Context) LibraryRepository + Folder(ctx context.Context) FolderRepository + Album(ctx context.Context) AlbumRepository + Artist(ctx context.Context) ArtistRepository + MediaFile(ctx context.Context) MediaFileRepository + Genre(ctx context.Context) GenreRepository + Tag(ctx context.Context) TagRepository + Playlist(ctx context.Context) PlaylistRepository + PlayQueue(ctx context.Context) PlayQueueRepository + Transcoding(ctx context.Context) TranscodingRepository + Player(ctx context.Context) PlayerRepository + Radio(ctx context.Context) RadioRepository + Share(ctx context.Context) ShareRepository + Property(ctx context.Context) PropertyRepository + User(ctx context.Context) UserRepository + UserProps(ctx context.Context) UserPropsRepository + ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository + Scrobble(ctx context.Context) ScrobbleRepository + Plugin(ctx context.Context) PluginRepository + + Resource(ctx context.Context, model any) ResourceRepository WithTx(block func(tx DataStore) error, scope ...string) error WithTxImmediate(block func(tx DataStore) error, scope ...string) error - // WithTxRetry runs block in a transaction, rerunning it while SQLite reports the database busy. - // For background work only (it can take minutes), and block must be safe to rerun after a rollback. - WithTxRetry(ctx context.Context, block func(ctx context.Context, tx DataStore) error, scope ...string) error GC(ctx context.Context, libraryIDs ...int) error } diff --git a/model/errors.go b/model/errors.go index b3f07565f..41029d316 100644 --- a/model/errors.go +++ b/model/errors.go @@ -1,17 +1,12 @@ package model -import ( - "errors" - - "github.com/deluan/rest" -) +import "errors" var ( - ErrNotFound = rest.ErrNotFound - ErrInvalidAuth = errors.New("invalid authentication") - ErrNotAuthorized = rest.ErrPermissionDenied - ErrExpired = errors.New("access expired") - ErrNotAvailable = errors.New("functionality not available") - ErrValidation = errors.New("validation error") - ErrPlaylistNotEditable = errors.New("playlist tracks are not editable") + ErrNotFound = errors.New("data not found") + ErrInvalidAuth = errors.New("invalid authentication") + ErrNotAuthorized = errors.New("not authorized") + ErrExpired = errors.New("access expired") + ErrNotAvailable = errors.New("functionality not available") + ErrValidation = errors.New("validation error") ) diff --git a/model/file_types_test.go b/model/file_types_test.go index 07dac645e..93301e151 100644 --- a/model/file_types_test.go +++ b/model/file_types_test.go @@ -18,10 +18,6 @@ var _ = Describe("File Types()", func() { Expect(model.IsAudioFile("test.flac")).To(BeTrue()) }) - It("returns true for a WebM file", func() { - Expect(model.IsAudioFile("test.webm")).To(BeTrue()) - }) - It("returns false for a non-audio file", func() { Expect(model.IsAudioFile("test.jpg")).To(BeFalse()) }) diff --git a/model/folder.go b/model/folder.go index 701fca8cd..7a769735e 100644 --- a/model/folder.go +++ b/model/folder.go @@ -1,7 +1,6 @@ package model import ( - "context" "fmt" "iter" "os" @@ -77,26 +76,17 @@ func NewFolder(lib Library, folderPath string) *Folder { type FolderCursor iter.Seq2[Folder, error] type FolderUpdateInfo struct { - UpdatedAt time.Time - Hash string - ImageFiles []string - ImagesUpdatedAt time.Time + UpdatedAt time.Time + Hash string } type FolderRepository interface { - Get(ctx context.Context, id string) (*Folder, error) - GetByPath(ctx context.Context, lib Library, path string) (*Folder, error) - GetAll(ctx context.Context, options ...QueryOptions) ([]Folder, error) - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - GetFolderUpdateInfo(ctx context.Context, 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(ctx context.Context, parent Folder, excludeFolderIDs []string) (bool, error) - Put(ctx context.Context, f *Folder) error - MarkMissing(ctx context.Context, missing bool, ids ...string) error - GetTouchedWithPlaylists(ctx context.Context) (FolderCursor, error) - // GetAllWithPlaylists returns all non-missing folders with playlists, ignoring - // the scan-timestamp gate used by GetTouchedWithPlaylists. - GetAllWithPlaylists(ctx context.Context) (FolderCursor, error) + Get(id string) (*Folder, error) + GetByPath(lib Library, path string) (*Folder, error) + GetAll(...QueryOptions) ([]Folder, error) + CountAll(...QueryOptions) (int64, error) + GetFolderUpdateInfo(lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error) + Put(*Folder) error + MarkMissing(missing bool, ids ...string) error + GetTouchedWithPlaylists() (FolderCursor, error) } diff --git a/model/genre.go b/model/genre.go index 147422e67..bb05e747e 100644 --- a/model/genre.go +++ b/model/genre.go @@ -1,11 +1,5 @@ package model -import ( - "context" - - "github.com/deluan/rest" -) - type Genre struct { ID string `structs:"id" json:"id,omitempty" toml:"id,omitempty" yaml:"id,omitempty"` Name string `structs:"name" json:"name"` @@ -16,7 +10,5 @@ type Genre struct { type Genres []Genre type GenreRepository interface { - rest.Repository[Genre] - GetAll(ctx context.Context, options ...QueryOptions) (Genres, error) - Get(ctx context.Context, id string) (*Genre, error) + GetAll(...QueryOptions) (Genres, error) } diff --git a/model/get_entity.go b/model/get_entity.go index cfb3968ef..60972b2e9 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -2,41 +2,29 @@ package model import ( "context" - "errors" ) // TODO: Should the type be encoded in the ID? func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { - entity, _, err := getEntity(ctx, ds, id) - return entity, err -} - -// GetEntityKindByID resolves a bare entity id to its artwork Kind, searching the same tables as -// GetEntityByID. It reports ErrNotFound when no entity owns the id. -func GetEntityKindByID(ctx context.Context, ds DataStore, id string) (Kind, error) { - _, kind, err := getEntity(ctx, ds, id) - return kind, err -} - -func getEntity(ctx context.Context, ds DataStore, id string) (any, Kind, error) { - getters := []struct { - kind Kind - get func() (any, error) - }{ - {KindArtistArtwork, func() (any, error) { return ds.Artist().Get(ctx, id) }}, - {KindAlbumArtwork, func() (any, error) { return ds.Album().Get(ctx, id) }}, - {KindPlaylistArtwork, func() (any, error) { return ds.Playlist().Get(ctx, id) }}, - {KindMediaFileArtwork, func() (any, error) { return ds.MediaFile().Get(ctx, id) }}, - {KindRadioArtwork, func() (any, error) { return ds.Radio().Get(ctx, id) }}, + ar, err := ds.Artist(ctx).Get(id) + if err == nil { + return ar, nil } - for _, g := range getters { - entity, err := g.get() - if err == nil { - return entity, g.kind, nil - } - if !errors.Is(err, ErrNotFound) { - return nil, Kind{}, err - } + al, err := ds.Album(ctx).Get(id) + if err == nil { + return al, nil } - return nil, Kind{}, ErrNotFound + pls, err := ds.Playlist(ctx).Get(id) + if err == nil { + return pls, nil + } + mf, err := ds.MediaFile(ctx).Get(id) + if err == nil { + return mf, nil + } + r, err := ds.Radio(ctx).Get(id) + if err == nil { + return r, nil + } + return nil, err } diff --git a/model/get_entity_test.go b/model/get_entity_test.go deleted file mode 100644 index 73e21a036..000000000 --- a/model/get_entity_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package model_test - -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("GetEntityByID", func() { - var ds *tests.MockDataStore - var ctx context.Context - - BeforeEach(func() { - ds = &tests.MockDataStore{} - ctx = GinkgoT().Context() - }) - - It("returns the entity matching the id", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) - entity, err := model.GetEntityByID(ctx, ds, "a1") - Expect(err).ToNot(HaveOccurred()) - Expect(entity).To(BeAssignableToTypeOf(&model.Album{})) - Expect(entity.(*model.Album).ID).To(Equal("a1")) - }) - - It("returns ErrNotFound when no entity matches", func() { - _, err := model.GetEntityByID(ctx, ds, "missing") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("propagates unexpected repository errors instead of reporting not-found", func() { - ds.Album().(*tests.MockAlbumRepo).SetError(true) - _, err := model.GetEntityByID(ctx, ds, "a1") - Expect(err).To(HaveOccurred()) - Expect(err).ToNot(MatchError(model.ErrNotFound)) - }) -}) - -var _ = Describe("GetEntityKindByID", func() { - var ds *tests.MockDataStore - var ctx context.Context - - BeforeEach(func() { - ds = &tests.MockDataStore{} - ctx = GinkgoT().Context() - }) - - It("returns the artwork kind for the matching id", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1"}}) - kind, err := model.GetEntityKindByID(ctx, ds, "a1") - Expect(err).ToNot(HaveOccurred()) - Expect(kind).To(Equal(model.KindAlbumArtwork)) - }) - - It("returns ErrNotFound when no entity matches", func() { - _, err := model.GetEntityKindByID(ctx, ds, "missing") - Expect(err).To(MatchError(model.ErrNotFound)) - }) -}) diff --git a/model/id/id.go b/model/id/id.go index 2885bc6ad..b54542898 100644 --- a/model/id/id.go +++ b/model/id/id.go @@ -2,36 +2,20 @@ package id import ( "crypto/md5" - "crypto/rand" "fmt" "math/big" "strings" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/nanoid" ) func NewRandom() string { - var b [16]byte - _, _ = rand.Read(b[:]) - return Encode(b) -} - -// Encode renders a 16-byte value as the canonical 22-char zero-padded base62 id. -func Encode(b [16]byte) string { - return fmt.Sprintf("%022s", new(big.Int).SetBytes(b[:]).Text(62)) -} - -// Decode is the exact inverse of Encode. -func Decode(s string) ([]byte, error) { - if len(s) != 22 { - return nil, fmt.Errorf("invalid id length %d", len(s)) + id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22) + if err != nil { + log.Error("Could not generate new ID", err) } - v, ok := new(big.Int).SetString(s, 62) - if !ok || v.Sign() < 0 { - return nil, fmt.Errorf("invalid base62 id %q", s) - } - if v.BitLen() > 128 { - return nil, fmt.Errorf("id %q overflows 128 bits", s) - } - return v.FillBytes(make([]byte, 16)), nil + return id } func NewHash(data ...string) string { @@ -40,7 +24,11 @@ func NewHash(data ...string) string { hash.Write([]byte(d)) hash.Write([]byte(string('\u200b'))) } - return Encode([16]byte(hash.Sum(nil))) + h := hash.Sum(nil) + bi := big.NewInt(0) + bi.SetBytes(h) + s := bi.Text(62) + return fmt.Sprintf("%022s", s) } func NewTagID(name, value string) string { diff --git a/model/id/id_test.go b/model/id/id_test.go deleted file mode 100644 index 407560a75..000000000 --- a/model/id/id_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package id_test - -import ( - "github.com/navidrome/navidrome/model/id" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Encode/Decode", func() { - It("encodes 16 bytes as 22-char zero-padded base62", func() { - Expect(id.Encode([16]byte{})).To(Equal("0000000000000000000000")) - allFF := [16]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff} - Expect(id.Encode(allFF)).To(Equal("7N42dgm5tFLK9N8MT7fHC7")) - }) - - It("round-trips arbitrary 16-byte values", func() { - b := [16]byte{0xe3, 0xb7, 0xfc, 0x2a, 0xe9, 0x44, 0x7b, 0xbe, - 0xc3, 0x7a, 0x13, 0xbf, 0x91, 0x6e, 0x3c, 0xf6} - s := id.Encode(b) - Expect(s).To(Equal("6VHl3uR4kss6sUPKA8Cwnk")) - Expect(id.Decode(s)).To(Equal(b[:])) - }) - - It("rejects invalid input", func() { - _, err := id.Decode("short") - Expect(err).To(HaveOccurred()) - _, err = id.Decode("!!!!!!!!!!!!!!!!!!!!!!") // 22 chars, not base62 - Expect(err).To(HaveOccurred()) - _, err = id.Decode("-000000000000000000001") // sign is not part of the alphabet - Expect(err).To(HaveOccurred()) - _, err = id.Decode("zzzzzzzzzzzzzzzzzzzzzz") // > 2^128 - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("NewRandom", func() { - It("emits 22-char canonical ids that always fit 128 bits", func() { - seen := make(map[string]struct{}) - for range 1000 { - s := id.NewRandom() - Expect(s).To(HaveLen(22)) - _, err := id.Decode(s) - Expect(err).ToNot(HaveOccurred(), "id %q must decode to 128 bits", s) - seen[s] = struct{}{} - } - Expect(seen).To(HaveLen(1000)) - }) -}) - -var _ = Describe("NewHash", func() { - It("keeps its historical output byte-for-byte (golden)", func() { - Expect(id.NewHash("test")).To(Equal("5cLJPkLA5DK2BADhoeotPk")) - Expect(id.NewHash("[unknown artist]")).To(Equal("7lsE5pS09fPS1VuFqwXbia")) - Expect(id.NewTagID("genre", "electronic")).To(Equal("7bLYq0Np81m1Wgy5N31nuG")) - }) - - It("always emits 22 decodable chars", func() { - h := id.NewHash("anything", "at", "all") - Expect(h).To(HaveLen(22)) - _, err := id.Decode(h) - Expect(err).ToNot(HaveOccurred()) - }) -}) diff --git a/model/image.go b/model/image.go index 30307fcea..68d8ae64c 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.String(), consts.ArtworkFolder, entityType, filename) + return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename) } diff --git a/model/library.go b/model/library.go index e80d22c89..bcb2864c8 100644 --- a/model/library.go +++ b/model/library.go @@ -1,13 +1,8 @@ package model import ( - "cmp" - "context" - "strings" "time" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/utils/slice" ) @@ -30,38 +25,6 @@ type Library struct { TotalSize int64 `json:"totalSize" db:"total_size"` TotalDuration float64 `json:"totalDuration" db:"total_duration"` DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"` - PIDAlbum string `json:"pidAlbum" db:"pid_album"` - PIDTrack string `json:"pidTrack" db:"pid_track"` - ScannedPIDAlbum string `json:"-" db:"scanned_pid_album"` - ScannedPIDTrack string `json:"-" db:"scanned_pid_track"` -} - -// PIDConfig holds the persistent ID specs used to compute track and album IDs. -type PIDConfig struct { - Track string - Album string -} - -// EffectivePID returns the PID specs in effect for this library: its own overrides, falling back to -// the global config. -func (l Library) EffectivePID() PIDConfig { - return PIDConfig{ - Track: cmp.Or(l.PIDTrack, conf.Server.PID.Track), - Album: cmp.Or(l.PIDAlbum, conf.Server.PID.Album), - } -} - -// PIDChanged reports whether the effective PID specs differ from the ones used by the last finished -// scan of this library. A library that was never scanned counts as changed. -func (l Library) PIDChanged() bool { - pid := l.EffectivePID() - return !strings.EqualFold(l.ScannedPIDAlbum, pid.Album) || !strings.EqualFold(l.ScannedPIDTrack, pid.Track) -} - -// NeedsPIDRescan reports whether the library has content imported with an old PID config, so it must be -// rescanned in full. A library that never finished a scan has nothing to regroup. -func (l Library) NeedsPIDRescan() bool { - return !l.LastScanAt.IsZero() && l.PIDChanged() } const ( @@ -76,26 +39,23 @@ func (l Libraries) IDs() []int { } type LibraryRepository interface { - rest.Repository[Library] - Get(ctx context.Context, id int) (*Library, error) + Get(id int) (*Library, error) // GetPath returns the path of the library with the given ID. // Its implementation must be optimized to avoid unnecessary queries. - GetPath(ctx context.Context, id int) (string, error) - GetAll(ctx context.Context, options ...QueryOptions) (Libraries, error) - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Put(ctx context.Context, l *Library, colsToUpdate ...string) error - Delete(ctx context.Context, id int) error - StoreMusicFolder(ctx context.Context) error - AddArtist(ctx context.Context, id int, artistID string) error + GetPath(id int) (string, error) + GetAll(...QueryOptions) (Libraries, error) + CountAll(...QueryOptions) (int64, error) + Put(*Library) error + Delete(id int) error + StoreMusicFolder() error + AddArtist(id int, artistID string) error // User-library association methods - GetUsersWithLibraryAccess(ctx context.Context, libraryID int) (Users, error) + GetUsersWithLibraryAccess(libraryID int) (Users, error) // TODO These methods should be moved to a core service - ScanBegin(ctx context.Context, id int, fullScan bool) error - ScanEnd(ctx context.Context, id int) error - // SetScannedPID records the PID specs used by the last finished scan of the library - SetScannedPID(ctx context.Context, id int, pid PIDConfig) error - ScanInProgress(ctx context.Context) (bool, error) - RefreshStats(ctx context.Context, id int) error + ScanBegin(id int, fullScan bool) error + ScanEnd(id int) error + ScanInProgress() (bool, error) + RefreshStats(id int) error } diff --git a/model/library_matcher.go b/model/library_matcher.go deleted file mode 100644 index 83af96f9f..000000000 --- a/model/library_matcher.go +++ /dev/null @@ -1,57 +0,0 @@ -package model - -import ( - "cmp" - "path/filepath" - "slices" - "strings" -) - -// LibraryMatcher finds the library that contains an absolute path. -type LibraryMatcher struct { - libraries Libraries - cleanedPaths []string -} - -// NewLibraryMatcher sorts the libraries longest path first, so /music-classical is checked before /music. -func NewLibraryMatcher(libs Libraries) *LibraryMatcher { - libs = slices.Clone(libs) - slices.SortFunc(libs, func(i, j Library) int { - return cmp.Compare(len(j.Path), len(i.Path)) - }) - cleanedPaths := make([]string, len(libs)) - for i, lib := range libs { - cleanedPaths[i] = filepath.Clean(lib.Path) - } - return &LibraryMatcher{libraries: libs, cleanedPaths: cleanedPaths} -} - -// FindLibrary returns the library whose path contains absolutePath. -func (lm *LibraryMatcher) FindLibrary(absolutePath string) (Library, bool) { - for i, libPath := range lm.cleanedPaths { - // A cleaned path only ends with a separator when it is a filesystem root - if strings.HasPrefix(absolutePath, libPath) && (len(absolutePath) == len(libPath) || - absolutePath[len(libPath)] == filepath.Separator || strings.HasSuffix(libPath, string(filepath.Separator))) { - return lm.libraries[i], true - } - } - return Library{}, false -} - -// LibraryRelativePath rebases an absolute path onto the library root, as the scanner's io/fs sees it -// (forward slashes). Relative paths, and absolute paths outside the library root, are returned unchanged. -func LibraryRelativePath(libPath, path string) string { - if !filepath.IsAbs(path) { - return path - } - // The library root may be relative (e.g. the default "./music"); it resolves against the same cwd - absLib, err := filepath.Abs(libPath) - if err != nil { - return path - } - rel, err := filepath.Rel(absLib, path) - if err != nil || !filepath.IsLocal(rel) { - return path - } - return filepath.ToSlash(rel) -} diff --git a/model/library_matcher_test.go b/model/library_matcher_test.go deleted file mode 100644 index 09e6f7e33..000000000 --- a/model/library_matcher_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package model_test - -import ( - "os" - "path/filepath" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("LibraryMatcher", func() { - // Paths are written Unix-style and converted, so they use the OS separator, as filepath.Abs output does - find := func(libs model.Libraries, path string) int { - for i := range libs { - libs[i].Path = filepath.FromSlash(libs[i].Path) - } - lib, ok := model.NewLibraryMatcher(libs).FindLibrary(filepath.FromSlash(path)) - if !ok { - return 0 - } - return lib.ID - } - - DescribeTable("matches the longest library path", - func(libs model.Libraries, path string, expectedID int) { - Expect(find(libs, path)).To(Equal(expectedID)) - }, - Entry("nested library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}, {ID: 3, Path: "/music-classical/opera"}}, "/music-classical/opera/subdir/track.mp3", 3), - Entry("sibling with a shared prefix", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical/track.mp3", 2), - Entry("shorter library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music/track.mp3", 1), - Entry("exact library root", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical", 2), - Entry("deeply nested libraries", model.Libraries{{ID: 1, Path: "/media"}, {ID: 2, Path: "/media/audio"}, {ID: 3, Path: "/media/audio/classical"}, {ID: 4, Path: "/media/audio/classical/baroque"}}, "/media/audio/classical/mozart/track.mp3", 3), - Entry("prefix that is not a path boundary", model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/ab"}, {ID: 3, Path: "/abc"}}, "/ab/file.mp3", 2), - Entry("special characters match literally", model.Libraries{{ID: 1, Path: "/music[test]"}, {ID: 2, Path: "/music(backup)"}}, "/music[test]/track.mp3", 1), - Entry("library path with a trailing slash", model.Libraries{{ID: 1, Path: "/music/"}}, "/music/track.mp3", 1), - Entry("library at the filesystem root", model.Libraries{{ID: 1, Path: "/"}}, "/music/track.mp3", 1), - Entry("nested library under a root library", model.Libraries{{ID: 1, Path: "/"}, {ID: 2, Path: "/music"}}, "/music/track.mp3", 2), - ) - - It("does not match a path outside every library", func() { - Expect(find(model.Libraries{{ID: 1, Path: "/music"}}, "/music-backup/track.mp3")).To(BeZero()) - }) - - It("does not match anything without libraries", func() { - Expect(find(nil, "/music/track.mp3")).To(BeZero()) - }) - - It("does not reorder the caller's libraries", func() { - libs := model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/abc"}} - model.NewLibraryMatcher(libs) - Expect(libs.IDs()).To(Equal([]int{1, 2})) - }) -}) - -var _ = Describe("LibraryRelativePath", func() { - // Paths are built with filepath so the "absolute" cases stay absolute on every OS - // (a Unix-style "/foo" is not absolute on Windows). - libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection")) - outside, _ := filepath.Abs(filepath.Join("somewhere", "else")) - - It("returns a relative path unchanged", func() { - Expect(model.LibraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection")) - }) - - It("rebases an absolute target when the library root is relative", func() { - cwd, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - Expect(model.LibraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock")) - }) - - It("rebases an absolute path that equals the library root to '.'", func() { - Expect(model.LibraryRelativePath(libRoot, libRoot)).To(Equal(".")) - }) - - It("rebases an absolute path under the library root", func() { - Expect(model.LibraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) - }) - - It("handles a trailing slash on the library path", func() { - Expect(model.LibraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) - }) - - It("leaves an absolute path outside the library root unchanged", func() { - Expect(model.LibraryRelativePath(libRoot, outside)).To(Equal(outside)) - }) - - It("returns an empty path unchanged", func() { - Expect(model.LibraryRelativePath(libRoot, "")).To(Equal("")) - }) -}) diff --git a/model/library_test.go b/model/library_test.go deleted file mode 100644 index 4e799e13f..000000000 --- a/model/library_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package model_test - -import ( - "encoding/json" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Library PID config", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.PID.Album = "global_album" - conf.Server.PID.Track = "global_track" - }) - - Describe("EffectivePID", func() { - It("falls back to the global config", func() { - Expect(model.Library{}.EffectivePID()).To(Equal(model.PIDConfig{Track: "global_track", Album: "global_album"})) - }) - It("uses the library overrides", func() { - lib := model.Library{PIDAlbum: "folder", PIDTrack: "title"} - Expect(lib.EffectivePID()).To(Equal(model.PIDConfig{Track: "title", Album: "folder"})) - }) - }) - - Describe("PIDChanged", func() { - It("is false when the scanned specs match, ignoring case", func() { - lib := model.Library{ScannedPIDAlbum: "GLOBAL_ALBUM", ScannedPIDTrack: "global_track"} - Expect(lib.PIDChanged()).To(BeFalse()) - }) - It("is true when the album override differs from the scanned spec", func() { - lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"} - Expect(lib.PIDChanged()).To(BeTrue()) - }) - It("is true when only the track spec changed", func() { - lib := model.Library{PIDTrack: "title", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"} - Expect(lib.PIDChanged()).To(BeTrue()) - }) - It("is true when the global config changed for a library without overrides", func() { - lib := model.Library{ScannedPIDAlbum: "old_album", ScannedPIDTrack: "global_track"} - Expect(lib.PIDChanged()).To(BeTrue()) - }) - It("is true for a library that was never scanned", func() { - Expect(model.Library{}.PIDChanged()).To(BeTrue()) - }) - }) - - Describe("NeedsPIDRescan", func() { - It("is false for a library that never finished a scan", func() { - Expect(model.Library{PIDAlbum: "folder"}.NeedsPIDRescan()).To(BeFalse()) - }) - It("is true for a scanned library whose PID config changed", func() { - lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()} - Expect(lib.NeedsPIDRescan()).To(BeTrue()) - }) - It("is false for a scanned library whose PID config did not change", func() { - lib := model.Library{ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()} - Expect(lib.NeedsPIDRescan()).To(BeFalse()) - }) - }) - - It("does not expose the scanned specs in JSON", func() { - data, err := json.Marshal(model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "secret_album", ScannedPIDTrack: "secret_track"}) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(ContainSubstring(`"pidAlbum":"folder"`)) - Expect(string(data)).ToNot(ContainSubstring("secret_")) - }) -}) diff --git a/model/lyrics.go b/model/lyrics.go index 111b1c2a9..f75f3b11b 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -1,93 +1,229 @@ package model import ( - "encoding/json" + "cmp" + "regexp" + "slices" + "strconv" "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"` - 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"` + 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"` } -// 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" +// 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):([^]]+)]`) ) func (l Lyrics) IsEmpty() bool { return len(l.Line) == 0 } -// 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 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 } -// 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 +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 + } } - return l.Kind + + 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 } 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 deleted file mode 100644 index 5a7d7871e..000000000 --- a/model/lyrics_benchmark_test.go +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index e5bd8d4f5..000000000 --- a/model/lyrics_lrc.go +++ /dev/null @@ -1,423 +0,0 @@ -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):([^]]+)]`) - - // Non-standard background-vocal tag: [bg: word...] attaches to the preceding line - bgTagRegex = regexp.MustCompile(`^\[bg:(.*)]$`) - - // Any other [name:value] line is a tag we don't support (e.g. [al:], [by:]) and must be skipped whole - unknownTagRegex = regexp.MustCompile(`^\[[A-Za-z][^]:]*:[^]]*]`) - - // 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 - hasBg := false - var timestamps []int64 - var pendingBg []string - - flushLine := func() { - value, cues := parseEnhancedLine(priorLine) - var merged bool - value, cues, merged = mergeBgLayers(value, cues, pendingBg) - hasBg = hasBg || merged - for idx := range timestamps { - startCopy := timestamps[idx] - structuredLines = append(structuredLines, Line{ - Start: &startCopy, - Value: value, - Cue: shiftELRCCues(cues, timestamps[idx]-timestamps[0]), - }) - } - timestamps = nil - pendingBg = nil - } - - 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 - } - - if bgMatch := bgTagRegex.FindStringSubmatch(line); bgMatch != nil { - if validLine { - pendingBg = append(pendingBg, bgMatch[1]) - } - continue - } - - if unknownTagRegex.MatchString(line) { - 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 { - flushLine() - } - - 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 { - flushLine() - } - - if hasBg { - fillMainAgentID(structuredLines) - } - - // 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, - } - if hasBg { - lyrics.Agents = []Agent{ - {ID: "main", Role: "main"}, - {ID: backgroundAgentID("main"), Role: "bg"}, - } - } - return &lyrics, nil -} - -// mergeBgLayers appends parsed [bg:] content to a line's value and cues as a -// background agent layer, mirroring the TTML background-vocal representation. -func mergeBgLayers(value string, cues []Cue, bgLines []string) (string, []Cue, bool) { - merged := false - for _, bg := range bgLines { - bgValue, bgCues := parseEnhancedLine(bg) - if len(bgCues) == 0 { - continue - } - offset := 0 - if value != "" { - offset = len(value) + 1 - value += " " - } - value += bgValue - for _, c := range bgCues { - c.ByteStart += offset - c.ByteEnd += offset - c.AgentID = backgroundAgentID("main") - cues = append(cues, c) - } - merged = true - } - return value, cues, merged -} - -func fillMainAgentID(lines []Line) { - for i := range lines { - for j := range lines[i].Cue { - if lines[i].Cue[j].AgentID == "" { - lines[i].Cue[j].AgentID = "main" - } - } - } -} - -// 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 deleted file mode 100644 index e8cd05352..000000000 --- a/model/lyrics_lrc_test.go +++ /dev/null @@ -1,298 +0,0 @@ -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 skip unknown tag lines instead of merging them into the prior line", func() { - lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[by: someone]\n[al: An album]\n[00:03.00]Next") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(HaveLen(2)) - - t1000, t1500, t3000 := int64(1000), int64(1500), int64(3000) - Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) - 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[1].Value).To(Equal("Next")) - }) - - It("should attach [bg:] background vocals to the previous line as a bg agent layer", func() { - lyrics, err := parseLRC("xxx", "[00:52.00]<00:52.00>Main <00:52.50>line\n[bg: <00:53.00>Okay<00:53.50>]\n[00:55.00]Next") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Agents).To(Equal([]Agent{ - {ID: "main", Role: "main"}, - {ID: "__nd_bg__|main", Role: "bg"}, - })) - Expect(lyrics.Line).To(HaveLen(2)) - - line := lyrics.Line[0] - Expect(line.Value).To(Equal("Main line Okay")) - Expect(line.Cue).To(Equal([]Cue{ - {Start: new(int64(52000)), End: new(int64(52500)), Value: "Main ", ByteStart: 0, ByteEnd: 4, AgentID: "main"}, - {Start: new(int64(52500)), End: new(int64(55000)), Value: "line", ByteStart: 5, ByteEnd: 8, AgentID: "main"}, - {Start: new(int64(53000)), End: new(int64(53500)), Value: "Okay", ByteStart: 10, ByteEnd: 13, AgentID: "__nd_bg__|main"}, - })) - Expect(lyrics.Line[1].Value).To(Equal("Next")) - }) - - It("should skip a [bg:] line that has no inline timing", func() { - lyrics, err := parseLRC("xxx", "[00:01.00]Hi\n[bg: untimed]\n[00:03.00]Next") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Agents).To(BeNil()) - Expect(lyrics.Line).To(HaveLen(2)) - Expect(lyrics.Line[0].Value).To(Equal("Hi")) - Expect(lyrics.Line[1].Value).To(Equal("Next")) - }) - - 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 deleted file mode 100644 index 49d416f3f..000000000 --- a/model/lyrics_lyricsfile.go +++ /dev/null @@ -1,283 +0,0 @@ -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 deleted file mode 100644 index 45899cda7..000000000 --- a/model/lyrics_lyricsfile_test.go +++ /dev/null @@ -1,300 +0,0 @@ -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 deleted file mode 100644 index a7d4b4e2a..000000000 --- a/model/lyrics_normalize.go +++ /dev/null @@ -1,171 +0,0 @@ -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 deleted file mode 100644 index 24faffd54..000000000 --- a/model/lyrics_normalize_test.go +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index 8aa095c50..000000000 --- a/model/lyrics_parse.go +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 0b47e55e9..000000000 --- a/model/lyrics_parse_test.go +++ /dev/null @@ -1,269 +0,0 @@ -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 deleted file mode 100644 index 319a59961..000000000 --- a/model/lyrics_srt.go +++ /dev/null @@ -1,158 +0,0 @@ -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 deleted file mode 100644 index 2c0ab2242..000000000 --- a/model/lyrics_srt_test.go +++ /dev/null @@ -1,30 +0,0 @@ -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 fd954ad26..382976872 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -1,69 +1,119 @@ -package model +package model_test import ( + . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -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)) +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)) }) - 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")) + 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"}, + })) }) }) diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go deleted file mode 100644 index 43d4699af..000000000 --- a/model/lyrics_ttml.go +++ /dev/null @@ -1,1296 +0,0 @@ -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 deleted file mode 100644 index bbdf5c7c4..000000000 --- a/model/lyrics_ttml_test.go +++ /dev/null @@ -1,551 +0,0 @@ -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 0c56b4825..6be8402ae 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -2,7 +2,7 @@ package model import ( "cmp" - "context" + "crypto/md5" "encoding/json" "fmt" "iter" @@ -12,26 +12,16 @@ import ( "strings" "time" - "github.com/deluan/rest" "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/slice" - "github.com/zeebo/xxh3" ) type MediaFile struct { Annotations `structs:"-" hash:"ignore"` Bookmarkable `structs:"-" hash:"ignore"` - ItemImage `structs:"-" hash:"ignore"` - - // AlbumImage is the parent album's artwork state, hydrated alongside the track's own so a - // song's Jellyfin album-art tag can be pixel-versioned without a second query. - AlbumImage ItemImage `structs:"-" json:"-" hash:"ignore"` ID string `structs:"id" json:"id" hash:"ignore"` PID string `structs:"pid" json:"-" hash:"ignore"` @@ -64,7 +54,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,omitempty"` + BitDepth int `structs:"bit_depth" json:"bitDepth"` Channels int `structs:"channels" json:"channels"` Codec string `structs:"codec" json:"codec"` ProbeData string `structs:"probe_data" json:"-" hash:"ignore"` @@ -81,7 +71,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"` @@ -107,29 +97,19 @@ type MediaFile struct { } func (mf MediaFile) FullTitle() string { - if s := mf.Tags.First(TagSubtitle); conf.Server.Subsonic.AppendSubtitle && s != "" { - return appendSuffix(mf.Title, s) + if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 { + return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0]) } return mf.Title } func (mf MediaFile) FullAlbumName() string { - if v := mf.Tags.First(TagAlbumVersion); conf.Server.Subsonic.AppendAlbumVersion && v != "" { - return appendSuffix(mf.Album, v) + if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 { + return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0]) } return mf.Album } -var bracketPairs = map[byte]byte{'(': ')', '[': ']', '{': '}', '<': '>'} - -func appendSuffix(base, suffix string) string { - suffix = strings.TrimSpace(suffix) - if len(suffix) >= 2 && bracketPairs[suffix[0]] == suffix[len(suffix)-1] { - return base + " " + suffix - } - return base + " (" + suffix + ")" -} - func (mf MediaFile) ContentType() string { return mime.TypeByExtension("." + mf.Suffix) } @@ -147,19 +127,17 @@ func (mf MediaFile) CoverArtID() ArtworkID { // otherwise it returns the album artwork ID. func (mf MediaFile) DiscCoverArtID() ArtworkID { if mf.DiscNumber > 0 { - return ArtworkID{Kind: KindDiscArtwork, ID: DiscArtworkID(mf.AlbumID, mf.DiscNumber), Hash: mf.ImageHash} + return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) } return mf.AlbumCoverArtID() } -// AlbumCoverArtID uses AlbumImage, not the track's own ItemImage: an album id must carry the -// album's content hash even when the track resolved art of its own. func (mf MediaFile) AlbumCoverArtID() ArtworkID { - return artworkIDFromAlbum(Album{ID: mf.AlbumID, ItemImage: mf.AlbumImage}) + return artworkIDFromAlbum(Album{ID: mf.AlbumID}) } func (mf MediaFile) StructuredLyrics() (LyricList, error) { - var lyrics LyricList + lyrics := LyricList{} err := json.Unmarshal([]byte(mf.Lyrics), &lyrics) if err != nil { return nil, err @@ -167,66 +145,11 @@ func (mf MediaFile) StructuredLyrics() (LyricList, error) { return lyrics, nil } -// HasEmbeddedLyrics reports whether the lyrics column holds any lyrics. It is never "" post-scan; -// no-lyrics is normalized to the "[]" sentinel, so string emptiness alone is meaningless. -func (mf MediaFile) HasEmbeddedLyrics() bool { - return mf.Lyrics != "" && mf.Lyrics != "[]" -} - // String is mainly used for debugging func (mf MediaFile) String() string { return mf.Path } -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{ @@ -234,7 +157,7 @@ func (mf MediaFile) Hash() string { ZeroNil: true, } hash, _ := hashstructure.Hash(mf, opts) - sum := xxh3.New() + sum := md5.New() sum.Write(fmt.Appendf(nil, "%d", hash)) sum.Write(mf.Tags.Hash()) sum.Write(mf.Participants.Hash()) @@ -302,7 +225,7 @@ func (mf MediaFile) inferCodecFromSuffix() string { return "dsd" case "m4a": // AAC if BitDepth==0, ALAC if BitDepth>0 - if gg.V(mf.BitDepth) > 0 { + if mf.BitDepth > 0 { return "alac" } return "aac" @@ -334,8 +257,6 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears := make([]int, 0, len(mfs)) originalDates := make([]string, 0, len(mfs)) releaseDates := make([]string, 0, len(mfs)) - rgAlbumGains := make([]*float64, 0, len(mfs)) - rgAlbumPeaks := make([]*float64, 0, len(mfs)) tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs)) a.Missing = true @@ -366,8 +287,6 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears = append(originalYears, m.OriginalYear) originalDates = append(originalDates, m.OriginalDate) releaseDates = append(releaseDates, m.ReleaseDate) - rgAlbumGains = append(rgAlbumGains, m.RGAlbumGain) - rgAlbumPeaks = append(rgAlbumPeaks, m.RGAlbumPeak) comments = append(comments, m.Comment) mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID) mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID) @@ -402,8 +321,6 @@ func (mfs MediaFiles) ToAlbum() Album { a.Comment, _ = allOrNothing(comments) a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds) a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds) - a.RGAlbumGain = mostFrequentPtr(rgAlbumGains) - a.RGAlbumPeak = mostFrequentPtr(rgAlbumPeaks) fixAlbumArtist(&a) return a @@ -433,32 +350,6 @@ func minMax(items []int) (int, int) { return mn, mx } -// mostFrequentPtr returns a pointer to the most common non-nil value, or nil if -// none. It counts by dereferenced value so a genuine 0.0 is a real candidate -// (slice.MostFrequent skips the zero value and compares pointers by identity). -func mostFrequentPtr(items []*float64) *float64 { - var counts map[float64]int - var best float64 - var bestCount int - for _, it := range items { - if it == nil { - continue - } - if counts == nil { - counts = map[float64]int{} - } - counts[*it]++ - if counts[*it] > bestCount { - bestCount = counts[*it] - best = *it - } - } - if bestCount == 0 { - return nil - } - return &best -} - func newer(t1, t2 time.Time) time.Time { if t1.After(t2) { return t1 @@ -539,43 +430,27 @@ func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string { type MediaFileCursor iter.Seq2[MediaFile, error] type MediaFileRepository interface { - rest.Repository[MediaFile] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - CountBySuffix(ctx context.Context, options ...QueryOptions) (map[string]int64, error) - Exists(ctx context.Context, id string) (bool, error) - Put(ctx context.Context, m *MediaFile) error - UpdateProbeData(ctx context.Context, id string, data string) error - Get(ctx context.Context, id string) (*MediaFile, error) - GetWithParticipants(ctx context.Context, id string) (*MediaFile, error) - GetAll(ctx context.Context, 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(ctx context.Context, options ...QueryOptions) (MediaFiles, error) - GetAllByTags(ctx context.Context, tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) - // MatchesCriteria reports whether the media file matches the criteria's rule - // expression, using the logged user's annotations. Limit and offset are ignored. - MatchesCriteria(ctx context.Context, id string, c criteria.Criteria) (bool, error) - GetCursor(ctx context.Context, options ...QueryOptions) (MediaFileCursor, error) - // GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given - // folders or their direct children. - GetAlbumIDsByFolder(ctx context.Context, lib Library, folderIDs ...string) ([]string, error) - // GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't - // pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors. - GetCursorWithArtwork(ctx context.Context, options ...QueryOptions) (MediaFileCursor, error) - Delete(ctx context.Context, id string) error - DeleteMissing(ctx context.Context, ids []string) error - DeleteAllMissing(ctx context.Context) (int64, error) - FindByPaths(ctx context.Context, paths []string) (MediaFiles, error) - // ReassignReferences moves annotations, bookmarks and playlist entries from prevID to newID, - // keeping newID's own row wherever a user has both. - ReassignReferences(ctx context.Context, prevID, newID string) error + CountAll(options ...QueryOptions) (int64, error) + CountBySuffix(options ...QueryOptions) (map[string]int64, error) + Exists(id string) (bool, error) + Put(m *MediaFile) error + UpdateProbeData(id string, data string) error + Get(id string) (*MediaFile, error) + GetWithParticipants(id string) (*MediaFile, error) + GetAll(options ...QueryOptions) (MediaFiles, error) + GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) + GetCursor(options ...QueryOptions) (MediaFileCursor, error) + Delete(id string) error + DeleteMissing(ids []string) error + DeleteAllMissing() (int64, error) + FindByPaths(paths []string) (MediaFiles, error) // The following methods are used exclusively by the scanner: - MarkMissing(ctx context.Context, missing bool, mfs ...*MediaFile) error - MarkMissingByFolder(ctx context.Context, missing bool, folderIDs ...string) error - GetMissingAndMatching(ctx context.Context, libId int) (MediaFileCursor, error) - FindRecentFilesByMBZTrackID(ctx context.Context, missing MediaFile, since time.Time) (MediaFiles, error) - FindRecentFilesByProperties(ctx context.Context, missing MediaFile, since time.Time) (MediaFiles, error) + MarkMissing(bool, ...*MediaFile) error + MarkMissingByFolder(missing bool, folderIDs ...string) error + GetMissingAndMatching(libId int) (MediaFileCursor, error) + FindRecentFilesByMBZTrackID(missing MediaFile, since time.Time) (MediaFiles, error) + FindRecentFilesByProperties(missing MediaFile, since time.Time) (MediaFiles, error) AnnotatedRepository BookmarkableRepository diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 9ca3489bb..3547ec4ef 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -218,11 +218,11 @@ var _ = Describe("MediaFiles", func() { {Tags: Tags{"genre": []string{"Alternative", "Rock"}}}, } }) - It("sets the correct Genre, sorted by frequency, then by order of appearance", func() { + It("sets the correct Genre, sorted by frequency, then alphabetically", func() { album := mfs.ToAlbum() Expect(album.Tags).To(HaveLen(2)) - Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Punk", "Alternative"})) - Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Happy", "Chill"})) + Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Alternative", "Punk"})) + Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Chill", "Happy"})) }) }) When("we have tags with mismatching case", func() { @@ -268,35 +268,6 @@ var _ = Describe("MediaFiles", func() { }) }) }) - Context("ReplayGain", func() { - It("picks the most frequent non-nil album gain and peak", func() { - mfs := MediaFiles{ - {Path: "a", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, - {Path: "b", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, - {Path: "c", RGAlbumGain: new(-5.0), RGAlbumPeak: new(1.0)}, - } - album := mfs.ToAlbum() - Expect(album.RGAlbumGain).ToNot(BeNil()) - Expect(*album.RGAlbumGain).To(Equal(-8.0)) - Expect(album.RGAlbumPeak).ToNot(BeNil()) - Expect(*album.RGAlbumPeak).To(Equal(0.9)) - }) - It("keeps a genuine 0.0 gain instead of dropping it", func() { - mfs := MediaFiles{ - {Path: "a", RGAlbumGain: new(0.0)}, - {Path: "b", RGAlbumGain: new(0.0)}, - } - album := mfs.ToAlbum() - Expect(album.RGAlbumGain).ToNot(BeNil()) - Expect(*album.RGAlbumGain).To(Equal(0.0)) - }) - It("leaves gain and peak nil when no track has a value", func() { - mfs := MediaFiles{{Path: "a"}, {Path: "b"}} - album := mfs.ToAlbum() - Expect(album.RGAlbumGain).To(BeNil()) - Expect(album.RGAlbumPeak).To(BeNil()) - }) - }) Context("Participants", func() { var album Album BeforeEach(func() { @@ -533,13 +504,6 @@ var _ = Describe("MediaFile", func() { Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"), Entry("returns just title when tag is absent", true, Tags{}, "Song"), Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"), - Entry("does not double parentheses when subtitle is already parenthesized", true, Tags{TagSubtitle: []string{"(non-explicit version)"}}, "Song (non-explicit version)"), - Entry("does not add parentheses when subtitle is wrapped in square brackets", true, Tags{TagSubtitle: []string{"[Live]"}}, "Song [Live]"), - Entry("does not add parentheses when subtitle is wrapped in curly braces", true, Tags{TagSubtitle: []string{"{Remix}"}}, "Song {Remix}"), - Entry("does not add parentheses when subtitle is wrapped in angle brackets", true, Tags{TagSubtitle: []string{""}}, "Song "), - Entry("adds parentheses when brackets do not match", true, Tags{TagSubtitle: []string{"[Live)"}}, "Song ([Live))"), - Entry("trims surrounding whitespace before wrapping", true, Tags{TagSubtitle: []string{" Live "}}, "Song (Live)"), - Entry("trims whitespace around an already-bracketed subtitle", true, Tags{TagSubtitle: []string{" (Live) "}}, "Song (Live)"), ) DescribeTable("FullAlbumName", func(enabled bool, tags Tags, expected string) { @@ -551,8 +515,6 @@ var _ = Describe("MediaFile", func() { Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"), Entry("returns just album name when tag is absent", true, Tags{}, "Album"), Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), - Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Deluxe Edition)"}}, "Album (Deluxe Edition)"), - Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Deluxe Edition]"}}, "Album [Deluxe Edition]"), ) Describe("CoverArtId", func() { It("returns its own id if it HasCoverArt", func() { @@ -602,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: new(bitDepth)} + mf := MediaFile{Suffix: suffix, BitDepth: bitDepth} Expect(mf.AudioCodec()).To(Equal(expected)) }, Entry("mp3", "mp3", 0, "mp3"), @@ -635,98 +597,13 @@ var _ = Describe("MediaFile", func() { ) It("prefers stored codec over suffix inference", func() { - mf := MediaFile{Codec: "ALAC", Suffix: "m4a"} + mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0} Expect(mf.AudioCodec()).To(Equal("alac")) }) }) }) -var _ = DescribeTable("MediaFile.HasEmbeddedLyrics", - func(lyrics string, expected bool) { - Expect(MediaFile{Lyrics: lyrics}.HasEmbeddedLyrics()).To(Equal(expected)) - }, - Entry("empty string (never-scanned zero value)", "", false), - Entry(`the post-scan "[]" no-lyrics sentinel`, "[]", false), - Entry("a stored lyric list", `[{"lang":"eng","line":[{"value":"la"}]}]`, true), -) - -var _ = Describe("MediaFile.Works", func() { - It("returns nil when there are no work tags", func() { - mf := MediaFile{} - 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() { - // Pins the hash formula: an accidental change spuriously re-imports every file on the next scan. - It("hashes to a stable value", func() { - Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("05fdf70bb0cbe090")) - Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b5daf6ac1009a538")) - }) - 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/legacy_ids.go b/model/metadata/legacy_ids.go index 7156c4f50..18a273550 100644 --- a/model/metadata/legacy_ids.go +++ b/model/metadata/legacy_ids.go @@ -9,19 +9,17 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" ) -// These legacy ID functions hash the same inputs as the original Navidrome ID generation, -// now emitted in the canonical base62 encoding (matching what the uniform-ids migration stores). +// These are the legacy ID functions that were used in the original Navidrome ID generation. +// They are kept here for backwards compatibility with existing databases. func legacyTrackID(mf model.MediaFile, prependLibId bool) string { - key := mf.Path + id := mf.Path if prependLibId && mf.LibraryID != model.DefaultLibraryID { - key = fmt.Sprintf("%d\\%s", mf.LibraryID, key) + id = fmt.Sprintf("%d\\%s", mf.LibraryID, id) } - sum := md5.Sum([]byte(key)) - return id.Encode(sum) + return fmt.Sprintf("%x", md5.Sum([]byte(id))) } func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string { @@ -35,8 +33,7 @@ func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string { if prependLibId && mf.LibraryID != model.DefaultLibraryID { albumPath = fmt.Sprintf("%d\\%s", mf.LibraryID, albumPath) } - sum := md5.Sum([]byte(albumPath)) - return id.Encode(sum) + return fmt.Sprintf("%x", md5.Sum([]byte(albumPath))) } func legacyMapAlbumArtistName(md Metadata) string { diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 2135824d2..824cad7c2 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -2,20 +2,20 @@ package metadata import ( "cmp" - "context" "encoding/json" "maps" "math" "strconv" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" ) -func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFile { +func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf := model.MediaFile{ - LibraryID: lib.ID, + LibraryID: libID, FolderID: folderID, Tags: maps.Clone(md.tags), } @@ -35,11 +35,7 @@ func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFi mf.DiscSubtitle = md.String(model.TagDiscSubtitle) mf.CatalogNum = md.String(model.TagCatalogNumber) mf.Comment = md.String(model.TagComment) - if f := md.NullableFloat(model.TagBPM); f != nil { - if r := math.Round(*f); r > 0 && r <= math.MaxInt32 { - mf.BPM = new(int(r)) - } - } + mf.BPM = int(math.Round(md.Float(model.TagBPM))) mf.Lyrics = md.mapLyrics() mf.ExplicitStatus = md.mapExplicitStatusTag() @@ -67,9 +63,7 @@ func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFi mf.Duration = md.Length() mf.BitRate = md.AudioProperties().BitRate mf.SampleRate = md.AudioProperties().SampleRate - if bd := md.AudioProperties().BitDepth; bd > 0 { - mf.BitDepth = new(bd) - } + mf.BitDepth = md.AudioProperties().BitDepth mf.Channels = md.AudioProperties().Channels mf.Codec = md.AudioProperties().Codec mf.Path = md.FilePath() @@ -83,9 +77,8 @@ func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFi mf.AlbumArtist = md.mapDisplayAlbumArtist(mf) // Persistent IDs - pid := lib.EffectivePID() - mf.PID = md.trackPID(mf, pid) - mf.AlbumID = md.albumID(mf, pid.Album) + mf.PID = md.trackPID(mf) + mf.AlbumID = md.albumID(mf, conf.Server.PID.Album) // BFR These IDs will go away once the UI handle multiple participants. // BFR For Legacy Subsonic compatibility, we will set them in the API handlers @@ -140,20 +133,17 @@ 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.ParseLyrics(ctx, "", lang, []byte(text)) + lyrics, err := model.ToLyrics(lang, text) if err != nil { - log.Warn(ctx, "Unexpected failure occurred when parsing lyrics", err) + log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err) continue } - for _, lyric := range lyrics { - if !lyric.IsEmpty() { - lyricList = append(lyricList, lyric) - } + if !lyrics.IsEmpty() { + lyricList = append(lyricList, *lyrics) } } diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index c19398841..e3adf3fae 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -8,6 +8,7 @@ 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" ) @@ -30,23 +31,9 @@ var _ = Describe("ToMediaFile", func() { var toMediaFile = func(tags model.RawTags) model.MediaFile { props.Tags = tags md = metadata.New("filepath", props) - return md.ToMediaFile(model.Library{ID: 1}, "folderID") + return md.ToMediaFile(1, "folderID") } - Describe("Persistent IDs", func() { - It("uses the library PID config for the album ID and for albumid in the track spec", func() { - props.Tags = model.RawTags{"ALBUM": {"Kind of Blue"}, "TITLE": {"So What"}} - md = metadata.New("Jazz/Loose/01.mp3", props) - - byTags := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "album", PIDTrack: "albumid,title"}, "folderID") - byFolder := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}, "folderID") - - Expect(byFolder.AlbumID).ToNot(Equal(byTags.AlbumID)) - Expect(byFolder.AlbumID).To(Equal(md.AlbumID(byFolder, "folder"))) - Expect(byFolder.PID).ToNot(Equal(byTags.PID)) - }) - }) - Describe("Dates", func() { It("should parse properly tagged dates ", func() { mf = toMediaFile(model.RawTags{ @@ -121,8 +108,8 @@ var _ = Describe("ToMediaFile", func() { expected := model.LyricList{ {Lang: "eng", Line: []model.Line{ - {Value: "This is", Start: new(int64(0))}, - {Value: "English SYLT", Start: new(int64(2500))}, + {Value: "This is", Start: P(int64(0))}, + {Value: "English SYLT", Start: P(int64(2500))}, }, Synced: true}, {Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false}, } @@ -131,41 +118,4 @@ 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()) - }) - It("leaves BPM nil when the tag does not fit in 32 bits", func() { - Expect(toMediaFile(model.RawTags{"BPM": {"4294967295"}}).BPM).To(BeNil()) - }) - It("leaves BPM nil when the tag is negative", func() { - Expect(toMediaFile(model.RawTags{"BPM": {"-120"}}).BPM).To(BeNil()) - }) - It("keeps the largest 32-bit BPM value", func() { - Expect(toMediaFile(model.RawTags{"BPM": {"2147483647"}}).BPM).To(Equal(new(2147483647))) - }) - }) - - 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 35f112a92..e8be6aaab 100644 --- a/model/metadata/map_participants.go +++ b/model/metadata/map_participants.go @@ -94,20 +94,18 @@ 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()) - 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) + + 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) } } @@ -173,16 +171,6 @@ 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) @@ -193,8 +181,11 @@ func (md Metadata) getRoleValues(role model.TagName) []string { if conf.Split == nil { conf = model.TagRolesConf() } - conf = conf.WithParticipantExceptions(role) - return splitParticipantValues(conf, values) + if len(conf.Split) > 0 { + values = conf.SplitTagValue(values) + return filterDuplicatedOrEmptyValues(values) + } + return values } // getArtistValues returns the values of a single or multi artist tag, splitting them if necessary @@ -211,8 +202,11 @@ func (md Metadata) getArtistValues(single, multi model.TagName) []string { if conf.Split == nil { conf = model.TagArtistsConf() } - conf = conf.WithParticipantExceptions(single) - return splitParticipantValues(conf, vSingle) + if len(conf.Split) > 0 { + vSingle = conf.SplitTagValue(vSingle) + return filterDuplicatedOrEmptyValues(vSingle) + } + return 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 db652fb8b..71cb9c1f2 100644 --- a/model/metadata/map_participants_test.go +++ b/model/metadata/map_participants_test.go @@ -4,8 +4,6 @@ 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" @@ -38,7 +36,7 @@ var _ = Describe("Participants", func() { var toMediaFile = func(tags model.RawTags) model.MediaFile { props.Tags = tags md = metadata.New("filepath", props) - return md.ToMediaFile(model.Library{ID: 1}, "folderID") + return md.ToMediaFile(1, "folderID") } Describe("ARTIST(S) tags", func() { @@ -565,37 +563,6 @@ 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() { @@ -717,26 +684,6 @@ 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() { @@ -835,60 +782,4 @@ 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 7843e7010..48928f989 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" "time" - "unicode/utf8" "github.com/google/uuid" "github.com/navidrome/navidrome/consts" @@ -148,20 +147,15 @@ func (md Metadata) tuple(key model.TagName) (int, int) { return 0, 0 } tuple := strings.Split(tag, "/") - total := md.first(key + "total") + t1, t2 := 0, 0 + t1, _ = strconv.Atoi(tuple[0]) if len(tuple) > 1 { - total = tuple[1] + t2, _ = strconv.Atoi(tuple[1]) + } else { + t2tag := md.first(key + "total") + t2, _ = strconv.Atoi(t2tag) } - return tagNumber(tuple[0]), tagNumber(total) -} - -// tagNumber rejects negatives and values above int32, so the DB stays readable by 32-bit builds. -func tagNumber(s string) int { - v, err := strconv.ParseInt(s, 10, 32) - if err != nil || v < 0 { - return 0 - } - return int(v) + return t1, t2 } var dateRegex = regexp.MustCompile(`([12]\d\d\d)`) @@ -211,7 +205,6 @@ 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: @@ -372,14 +365,6 @@ func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value s if len(value) > maxLength { log.Trace("Truncated tag value", "tag", tagName, "value", value, "length", len(value), "maxLength", maxLength) value = value[:maxLength] - // Drop the partial rune the cut may have left: at most 3 trailing bytes, - // so a pre-existing invalid run elsewhere is never consumed. - for range 3 { - if r, size := utf8.DecodeLastRuneInString(value); r != utf8.RuneError || size != 1 { - break - } - value = value[:len(value)-1] - } } switch tag.Type { @@ -401,14 +386,11 @@ func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value s return "" } case model.TagTypeUUID: - u, err := uuid.Parse(value) + _, err := uuid.Parse(value) if err != nil { log.Trace("Invalid UUID tag value", "tag", tagName, "value", value) return "" } - // Store the canonical form: uuid.Parse accepts braces, urn: prefixes and any - // two-byte wrapper, and a wrapped value would never match an exact-match query - value = u.String() } return value } diff --git a/model/metadata/metadata_internal_test.go b/model/metadata/metadata_internal_test.go deleted file mode 100644 index 99fbba328..000000000 --- a/model/metadata/metadata_internal_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package metadata - -import ( - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = DescribeTable("sanitize truncation with a tiny MaxLength", - // A value of only continuation bytes drains the partial-rune loop to empty; the loop - // must stop there instead of slicing value[:-1] and panicking. - func(maxLength int, value string) { - Expect(func() { - Expect(sanitize("file.mp3", "title", model.TagConf{MaxLength: maxLength}, value)).To(Equal("")) - }).NotTo(Panic()) - }, - Entry("maxLength 1", 1, "\x80\x80"), - Entry("maxLength 2", 2, "\x80\x80\x80"), - Entry("maxLength 3", 3, "\x80\x80\x80\x80"), -) diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index a1a675006..663e306c4 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -4,11 +4,11 @@ import ( "os" "strings" "time" - "unicode/utf8" "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 +106,7 @@ var _ = Describe("Metadata", func() { props.Tags = model.RawTags{ "Title": {strings.Repeat("a", 2048)}, "Comment": {strings.Repeat("a", 8192)}, - "lyrics:xxx": {strings.Repeat("a", 2_000_000)}, + "lyrics:xxx": {strings.Repeat("a", 60000)}, } md = metadata.New(filePath, props) @@ -117,49 +117,11 @@ 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(1048570)) + // the key portion and separator + Expect(pair[0].Value()).To(HaveLen(32762)) }) - It("should not split a multi-byte character when truncating", func() { - // 1024 is not a multiple of 3, so a byte-wise cut lands mid-rune. - props.Tags = model.RawTags{ - "Title": {strings.Repeat("日", 2048)}, - } - md = metadata.New(filePath, props) - - title := md.String(model.TagTitle) - Expect(utf8.ValidString(title)).To(BeTrue(), "truncation produced invalid UTF-8") - Expect(len(title)).To(BeNumerically("<=", 1024)) - }) - - It("should keep invalid bytes that are not at the truncation point", func() { - props.Tags = model.RawTags{ - "Title": {"a\xffb" + strings.Repeat("c", 2048)}, - } - md = metadata.New(filePath, props) - - Expect(md.String(model.TagTitle)).To(HaveLen(1024)) - }) - - DescribeTable("should normalize UUID tags to their canonical form", - func(raw, expected string) { - props.Tags = model.RawTags{"musicbrainz_artistid": {raw}} - md = metadata.New(filePath, props) - - Expect(md.String(model.TagMusicBrainzArtistID)).To(Equal(expected)) - }, - Entry("canonical", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("uppercase", "F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("braced", "{f81d4fae-7dec-11d0-a765-00a0c91e6bf6}", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("urn prefix", "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("quoted", `"f81d4fae-7dec-11d0-a765-00a0c91e6bf6"`, "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("no dashes", "f81d4fae7dec11d0a76500a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("not a uuid", "the beatles", ""), - ) - It("should split multiple values", func() { props.Tags = model.RawTags{ "Genre": {"Rock/Pop;;Punk"}, @@ -168,21 +130,6 @@ 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", @@ -226,10 +173,6 @@ var _ = Describe("Metadata", func() { Entry(nil, "2/10", "", 2, 10), Entry(nil, "", "", 0, 0), Entry(nil, "A", "", 0, 0), - Entry("ignores values that do not fit in 32 bits", "4294967295", "4294967296", 0, 0), - Entry("ignores a total that does not fit in 32 bits", "2/4294967295", "", 2, 0), - Entry("keeps the largest 32-bit value", "2147483647", "", 2147483647, 0), - Entry("ignores negative values", "-1", "-2", 0, 0), ) Describe("Performers", func() { @@ -323,7 +266,7 @@ var _ = Describe("Metadata", func() { tag: {tagValue}, } md = metadata.New(filePath, props) - return md.ToMediaFile(model.Library{}, "0") + return md.ToMediaFile(0, "0") } DescribeTable("Gain", @@ -331,8 +274,8 @@ var _ = Describe("Metadata", func() { mf := createMF("replaygain_track_gain", tagValue) Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", new(0.0)), - Entry("1.2dB", "1.2dB", new(1.2)), + Entry("0", "0", gg.P(0.0)), + Entry("1.2dB", "1.2dB", gg.P(1.2)), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), Entry("NaN", "NaN", nil), @@ -342,9 +285,9 @@ var _ = Describe("Metadata", func() { mf := createMF("replaygain_track_peak", tagValue) Expect(mf.RGTrackPeak).To(Equal(expected)) }, - Entry("0", "0", new(0.0)), - Entry("1.0", "1.0", new(1.0)), - Entry("0.5", "0.5", new(0.5)), + 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("Invalid dB suffix", "0.7dB", nil), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), @@ -356,8 +299,8 @@ var _ = Describe("Metadata", func() { Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", new(5.0)), - Entry("-3776", "-3776", new(-9.75)), + Entry("0", "0", gg.P(5.0)), + Entry("-3776", "-3776", gg.P(-9.75)), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), ) diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go index b66ce824a..db315dc6b 100644 --- a/model/metadata/persistent_ids.go +++ b/model/metadata/persistent_ids.go @@ -2,11 +2,11 @@ package metadata import ( "cmp" - "errors" "fmt" "path/filepath" "strings" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -22,13 +22,12 @@ type hashFunc = func(...string) string // attributes. Attributes can be either tags or processed values like folder, // albumid, albumartistid, etc. For each field, it gets all its attribute values // and concatenates them, then hashes the result. If a field is empty, it is -// skipped and the function looks for the next field. albumSpec is the album PID -// spec used to resolve the `albumid` attribute. +// skipped and the function looks for the next field. // // Taking hash as a parameter (instead of closing over it in a factory) keeps // mf on the stack: closing over mf would force the whole ~1KB MediaFile to the // heap on every call. -func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prependLibId bool, hash hashFunc) string { +func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string { switch spec { case "track_legacy": return legacyTrackID(mf, prependLibId) @@ -42,7 +41,7 @@ func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prepend values := make([]string, len(attributes)) hasValue := false for i, attr := range attributes { - v := getPIDAttr(mf, md, attr, prependLibId, spec, albumSpec, hash) + v := getPIDAttr(mf, md, attr, prependLibId, spec, hash) if v != "" { hasValue = true } @@ -59,15 +58,15 @@ func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prepend return hash(pid) } -func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec, albumSpec string, hash hashFunc) string { +func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string { attr = strings.TrimSpace(strings.ToLower(attr)) switch attr { case "albumid": - if spec == albumSpec { + if spec == conf.Server.PID.Album { log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec) return "" } - return computePID(mf, md, albumSpec, albumSpec, prependLibId, hash) + return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash) case "folder": return filepath.Dir(mf.Path) case "albumartistid": @@ -80,50 +79,18 @@ func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, return md.String(model.TagName(attr)) } -// ValidatePIDSpec checks a PID override before it is stored; empty means "use the global config". -// Aliases resolve to empty at scan time: accepted only in track specs, because the default one uses them. -func ValidatePIDSpec(spec string, isAlbum bool) error { - switch { - case spec == "", isAlbum && spec == "album_legacy", !isAlbum && spec == "track_legacy": - return nil - } - for field := range strings.SplitSeq(spec, "|") { - for attr := range strings.SplitSeq(field, ",") { - attr = strings.TrimSpace(strings.ToLower(attr)) - switch attr { - case "": - return fmt.Errorf("empty attribute in %q", spec) - case "albumid": - if isAlbum { - return errors.New("albumid cannot be used in an album PID") - } - case "folder", "albumartistid": - default: - name, ok := model.CanonicalTagName(attr) - if !ok { - return fmt.Errorf("unknown attribute %q", attr) - } - if isAlbum && string(name) != attr { - return fmt.Errorf("use the tag name %q instead of its alias %q", name, attr) - } - } - } - } - return nil -} - -func (md Metadata) trackPID(mf model.MediaFile, pid model.PIDConfig) string { - return computePID(mf, md, pid.Track, pid.Album, true, id.NewHash) +func (md Metadata) trackPID(mf model.MediaFile) string { + return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash) } func (md Metadata) albumID(mf model.MediaFile, pidConf string) string { - return computePID(mf, md, pidConf, pidConf, true, id.NewHash) + return computePID(mf, md, pidConf, true, id.NewHash) } // BFR Must be configurable? func (md Metadata) artistID(name string) string { mf := model.MediaFile{AlbumArtist: name} - return computePID(mf, md, "albumartistid", "", false, id.NewHash) + return computePID(mf, md, "albumartistid", false, id.NewHash) } func (md Metadata) mapTrackTitle() string { diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index 9f6eaf1f4..eb66d11d1 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -3,7 +3,8 @@ package metadata import ( "strings" - "github.com/navidrome/navidrome/consts" + "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" @@ -12,18 +13,16 @@ import ( var _ = Describe("getPID", func() { var ( - md Metadata - mf model.MediaFile - sum hashFunc - albumSpec string + md Metadata + mf model.MediaFile + sum hashFunc ) getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { - return computePID(mf, md, spec, albumSpec, prependLibId, sum) + return computePID(mf, md, spec, prependLibId, sum) } BeforeEach(func() { sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" } - albumSpec = consts.DefaultAlbumPID }) Context("attributes are tags", func() { @@ -67,7 +66,8 @@ var _ = Describe("getPID", func() { Context("calculated attributes", func() { BeforeEach(func() { - albumSpec = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" + DeferCleanup(configtest.SetupConfig()) + conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" }) When("field is title", func() { It("should return the pid", func() { @@ -121,8 +121,8 @@ var _ = Describe("getPID", func() { When("albumid configuration refers to albumid recursively", func() { It("should avoid infinite recursion", func() { // Reproduce the issue from #4920 - albumSpec = "albumid,album,albumversion,releasedate" - spec := albumSpec + conf.Server.PID.Album = "albumid,album,albumversion,releasedate" + spec := conf.Server.PID.Album md.tags = map[model.TagName][]string{ "album": {"Album Name"}, "albumversion": {"Version"}, @@ -205,7 +205,8 @@ var _ = Describe("getPID", func() { }) When("prependLibId is true with nested albumid", func() { It("should handle nested albumid calls correctly", func() { - albumSpec = "album" + DeferCleanup(configtest.SetupConfig()) + conf.Server.PID.Album = "album" spec := "albumid" md.tags = map[model.TagName][]string{"album": {"Test Album"}} mf.AlbumArtist = "Test Artist" @@ -217,21 +218,6 @@ var _ = Describe("getPID", func() { }) Context("legacy specs", func() { - It("emits canonical 22-char base62 ids", func() { - mf := model.MediaFile{Path: "/music/a.mp3", LibraryID: 1} - // md5("/music/a.mp3") = e3b7fc2ae9447bbec37a13bf916e3cf6 re-encoded as base62 - Expect(legacyTrackID(mf, false)).To(Equal("6VHl3uR4kss6sUPKA8Cwnk")) - }) - It("prepends the library id for a non-default library", func() { - mf := model.MediaFile{Path: "/music/a.mp3", LibraryID: 2} - // id.Encode(md5.Sum([]byte("2\\/music/a.mp3"))) - Expect(legacyTrackID(mf, true)).To(Equal("4EK5DHQBMeFuDHw6S3iooO")) - }) - It("emits a canonical album id (golden)", func() { - mf := model.MediaFile{LibraryID: 1} - // id.Encode(md5.Sum([]byte("[unknown artist]\\[unknown album]"))) - Expect(legacyAlbumID(mf, Metadata{}, false)).To(Equal("6xBmxSAUFJSQuW7UvwCq8X")) - }) Context("track_legacy", func() { When("library ID is default (1)", func() { It("should not prepend library ID even when prependLibId is true", func() { @@ -305,34 +291,3 @@ var _ = Describe("getPID", func() { }) }) }) - -var _ = Describe("ValidatePIDSpec", func() { - DescribeTable("accepts valid specs", - func(spec string, isAlbum bool) { - Expect(ValidatePIDSpec(spec, isAlbum)).To(Succeed()) - }, - Entry("empty, meaning the global config", "", true), - Entry("default album spec", consts.DefaultAlbumPID, true), - Entry("default track spec, which uses tag aliases", consts.DefaultTrackPID, false), - Entry("folder", "folder", true), - Entry("album legacy", "album_legacy", true), - Entry("track legacy", "track_legacy", false), - Entry("computed attributes", "albumartistid,album|title", true), - Entry("albumid in a track spec", "albumid,title", false), - Entry("spaces and mixed case", "MusicBrainz_AlbumID | Folder", true), - ) - - DescribeTable("rejects invalid specs", - func(spec string, isAlbum bool, msg string) { - Expect(ValidatePIDSpec(spec, isAlbum)).To(MatchError(ContainSubstring(msg))) - }, - Entry("unknown tag", "albmversion", true, `unknown attribute "albmversion"`), - Entry("empty field", "album||title", true, "empty attribute"), - Entry("empty attribute", "album,,title", true, "empty attribute"), - Entry("trailing separator", "album|", true, "empty attribute"), - Entry("albumid in an album spec", "albumid,album", true, "albumid"), - Entry("tag alias in an album spec", "talb", true, `use the tag name "album" instead of its alias "talb"`), - Entry("track legacy in an album spec", "track_legacy", true, `unknown attribute "track_legacy"`), - Entry("album legacy in a track spec", "album_legacy", false, `unknown attribute "album_legacy"`), - ) -}) diff --git a/model/participants.go b/model/participants.go index b5390d22c..afbda10de 100644 --- a/model/participants.go +++ b/model/participants.go @@ -2,12 +2,12 @@ package model import ( "cmp" + "crypto/md5" "fmt" "slices" "strings" "github.com/navidrome/navidrome/utils/slice" - "github.com/zeebo/xxh3" ) var ( @@ -193,7 +193,7 @@ func (p Participants) Hash() []byte { flattened = append(flattened, role.String()+":"+strings.Join(ids, "/")) } slices.Sort(flattened) - sum := xxh3.New() + sum := md5.New() sum.Write([]byte(strings.Join(flattened, "|"))) return sum.Sum(nil) } diff --git a/model/player.go b/model/player.go index c03058419..39ea99d1a 100644 --- a/model/player.go +++ b/model/player.go @@ -1,10 +1,7 @@ package model import ( - "context" "time" - - "github.com/deluan/rest" ) type Player struct { @@ -21,20 +18,14 @@ type Player struct { MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"` ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"` ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"` - HasAPIKey bool `structs:"-" db:"has_api_key" json:"hasApiKey"` - APIKey *string `structs:"-" json:"apiKey,omitempty"` } type Players []Player type PlayerRepository interface { - rest.Repository[Player] - rest.Persistable[Player] - Get(ctx context.Context, id string) (*Player, error) - FindMatch(ctx context.Context, userId, client, userAgent string) (*Player, error) - Put(ctx context.Context, p *Player) error - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - CountByClient(ctx context.Context, options ...QueryOptions) (map[string]int64, error) - FindByAPIKey(ctx context.Context, key string) (*Player, error) - SetAPIKey(ctx context.Context, playerID, key string) error + Get(id string) (*Player, error) + FindMatch(userId, client, userAgent string) (*Player, error) + Put(p *Player) error + CountAll(...QueryOptions) (int64, error) + CountByClient(...QueryOptions) (map[string]int64, error) } diff --git a/model/playlist.go b/model/playlist.go index d2ed97682..dc549f039 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,26 +1,15 @@ package model import ( - "context" - "iter" - "maps" - "os" - "path/filepath" "slices" "strconv" "time" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/criteria" ) type Playlist struct { - Annotations `structs:"-"` - ItemImage `structs:"-"` - ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` Comment string `structs:"comment" json:"comment"` @@ -37,7 +26,6 @@ type Playlist struct { ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"` CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` - ImportedHash string `structs:"imported_hash" json:"-"` // SmartPlaylist attributes Rules *criteria.Criteria `structs:"rules" json:"rules"` @@ -48,20 +36,6 @@ func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } -// TracksEditable reports whether the track list is user-owned rather than server-managed. -func (pls Playlist) TracksEditable() bool { - return !pls.IsSmartPlaylist() && !pls.Sync -} - -// RefreshDelay returns the playlist's own refresh window when set, falling -// back to the global SmartPlaylistRefreshDelay. -func (pls Playlist) RefreshDelay() time.Duration { - if pls.IsSmartPlaylist() && pls.Rules.RefreshDelay > 0 { - return pls.Rules.RefreshDelay - } - return conf.Server.SmartPlaylistRefreshDelay -} - func (pls Playlist) MediaFiles() MediaFiles { if len(pls.Tracks) == 0 { return nil @@ -143,84 +117,20 @@ func (pls Playlist) UploadedImagePath() string { return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage) } -// NormalizedRules returns the rules with child playlist paths resolved to absolute, OS-native paths. -func (pls Playlist) NormalizedRules() *criteria.Criteria { - if pls.Rules == nil || pls.Rules.Expression == nil { - return pls.Rules - } - - rules := *pls.Rules - rules.Expression = normalizePlaylistPaths(pls.Rules.Expression, pls.Path) - return &rules -} - -func normalizePlaylistPaths(inputRule criteria.Expression, referencingPlaylistPath string) criteria.Expression { - switch rule := inputRule.(type) { - case criteria.Any: - anyCriteria := make(criteria.Any, len(rule)) - for i, rules := range rule { - anyCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath) - } - return anyCriteria - case criteria.All: - allCriteria := make(criteria.All, len(rule)) - for i, rules := range rule { - allCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath) - } - return allCriteria - case criteria.InPlaylist: - return criteria.InPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath)) - case criteria.NotInPlaylist: - return criteria.NotInPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath)) - } - - return inputRule -} - -func normalizeChildPathRule(rule map[string]any, referencingPlaylistPath string) map[string]any { - path, ok := rule["path"].(string) - if !ok || path == "" { - return rule - } - - // References use forward slashes to stay portable, while Playlist.Path is OS-native. - path = filepath.FromSlash(path) - switch { - case isAbsPlaylistRef(path): - path = filepath.Clean(path) - case referencingPlaylistPath != "": - path = filepath.Join(filepath.Dir(referencingPlaylistPath), path) - default: - log.Warn("Cannot resolve relative playlist reference: playlist has no file path", "reference", path) - } - normalized := maps.Clone(rule) - normalized["path"] = path - return normalized -} - -// filepath.IsAbs rejects a bare leading separator on Windows, but that is how Unix spells absolute. -func isAbsPlaylistRef(path string) bool { - return filepath.IsAbs(path) || os.IsPathSeparator(path[0]) -} - type Playlists []Playlist -type PlaylistCursor iter.Seq2[Playlist, error] - type PlaylistRepository interface { - rest.Repository[Playlist] - rest.Persistable[Playlist] - AnnotatedRepository - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Exists(ctx context.Context, id string) (bool, error) - Put(ctx context.Context, pls *Playlist, cols ...string) error - Get(ctx context.Context, id string) (*Playlist, error) - GetWithTracks(ctx context.Context, id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) - GetAll(ctx context.Context, options ...QueryOptions) (Playlists, error) - GetCursor(ctx context.Context, options ...QueryOptions) (PlaylistCursor, error) - FindByPath(ctx context.Context, path string) (*Playlist, error) - Tracks(ctx context.Context, playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository - GetPlaylists(ctx context.Context, mediaFileId string) (Playlists, error) + ResourceRepository + CountAll(options ...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Put(pls *Playlist, cols ...string) error + Get(id string) (*Playlist, error) + GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) + GetAll(options ...QueryOptions) (Playlists, error) + FindByPath(path string) (*Playlist, error) + Delete(id string) error + Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository + GetPlaylists(mediaFileId string) (Playlists, error) } type PlaylistTrack struct { @@ -240,21 +150,15 @@ func (plt PlaylistTracks) MediaFiles() MediaFiles { return mfs } -type PlaylistTrackCursor iter.Seq2[PlaylistTrack, error] - type PlaylistTrackRepository interface { - rest.Repository[PlaylistTrack] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - GetAll(ctx context.Context, options ...QueryOptions) (PlaylistTracks, error) - GetCursor(ctx context.Context, options ...QueryOptions) (PlaylistTrackCursor, error) - GetAlbumIDs(ctx context.Context, options ...QueryOptions) ([]string, error) - GetMediaFileIDs(ctx context.Context, options ...QueryOptions) ([]string, error) - Add(ctx context.Context, mediaFileIds []string) (int, error) - Insert(ctx context.Context, mediaFileIds []string, pos int) (int, error) - AddAlbums(ctx context.Context, albumIds []string) (int, error) - AddArtists(ctx context.Context, artistIds []string) (int, error) - AddDiscs(ctx context.Context, discs []DiscID) (int, error) - Delete(ctx context.Context, id ...string) error - DeleteAll(ctx context.Context) error - Reorder(ctx context.Context, pos int, newPos int) error + ResourceRepository + GetAll(options ...QueryOptions) (PlaylistTracks, error) + GetAlbumIDs(options ...QueryOptions) ([]string, error) + Add(mediaFileIds []string) (int, error) + AddAlbums(albumIds []string) (int, error) + AddArtists(artistIds []string) (int, error) + AddDiscs(discs []DiscID) (int, error) + Delete(id ...string) error + DeleteAll() error + Reorder(pos int, newPos int) error } diff --git a/model/playlist_test.go b/model/playlist_test.go index 7ffb69b63..9ed24f00f 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -1,13 +1,7 @@ package model_test import ( - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -49,142 +43,4 @@ var _ = Describe("Playlist", func() { Expect(pls.ToM3U8()).To(Equal(expected)) }) }) - - Describe("RefreshDelay", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SmartPlaylistRefreshDelay = 5 * time.Second - }) - - It("returns the global config value when rules have no refreshDelay", func() { - pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}} - Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) - }) - - It("returns the per-playlist value when set", func() { - pls := model.Playlist{Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.Is{"loved": true}}, - RefreshDelay: 24 * time.Hour, - }} - Expect(pls.RefreshDelay()).To(Equal(24 * time.Hour)) - }) - - It("returns the global value for non-smart playlists", func() { - pls := model.Playlist{} - Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) - }) - }) - - Describe("TracksEditable", func() { - It("is true for a plain playlist", func() { - Expect(model.Playlist{}.TracksEditable()).To(BeTrue()) - }) - - It("is false for a smart playlist", func() { - pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.Is{"loved": true}}} - Expect(pls.TracksEditable()).To(BeFalse()) - }) - - It("is false for a synced playlist", func() { - Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse()) - }) - }) - - Describe("NormalizedRules()", func() { - // absPath builds an OS-native absolute path so these specs also run on Windows. - absPath := func(parts ...string) string { - abs, err := filepath.Abs(filepath.Join(parts...)) - Expect(err).ToNot(HaveOccurred()) - return abs - } - normalize := func(pls model.Playlist) criteria.Expression { - return pls.NormalizedRules().Expression - } - - It("resolves relative references against the playlist folder", func() { - pls := model.Playlist{ - Path: absPath("test", "nested", "my-playlist.nsp"), - Rules: &criteria.Criteria{Expression: criteria.All{ - criteria.InPlaylist{"path": "../up.m3u"}, - criteria.NotInPlaylist{"path": "./sibling.nsp"}, - criteria.Any{criteria.InPlaylist{"path": "sub/deep.nsp"}}, - }}, - } - Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ - criteria.InPlaylist{"path": absPath("test", "up.m3u")}, - criteria.NotInPlaylist{"path": absPath("test", "nested", "sibling.nsp")}, - criteria.Any{criteria.InPlaylist{"path": absPath("test", "nested", "sub", "deep.nsp")}}, - })) - }) - - It("cleans absolute references", func() { - dirty := absPath("music") + string(filepath.Separator) + "." + string(filepath.Separator) + "child.nsp" - pls := model.Playlist{ - Path: absPath("test", "my-playlist.nsp"), - Rules: &criteria.Criteria{Expression: criteria.All{criteria.NotInPlaylist{"path": dirty}}}, - } - Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ - criteria.NotInPlaylist{"path": absPath("music", "child.nsp")}, - })) - }) - - It("treats a leading slash as absolute on every OS", func() { - pls := model.Playlist{ - Path: absPath("test", "my-playlist.nsp"), - Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "/other/./root.m3u"}}}, - } - Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ - criteria.InPlaylist{"path": filepath.FromSlash("/other/root.m3u")}, - })) - }) - - It("leaves empty paths and id references untouched", func() { - pls := model.Playlist{ - Path: absPath("test", "my-playlist.nsp"), - Rules: &criteria.Criteria{Expression: criteria.All{ - criteria.InPlaylist{"path": ""}, - criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"}, - criteria.Eq{"artist": "Bob Dealin"}, - }}, - } - Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ - criteria.InPlaylist{"path": ""}, - criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"}, - criteria.Eq{"artist": "Bob Dealin"}, - })) - }) - - It("skips relative references when the playlist has no path", func() { - pls := model.Playlist{ - Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "../up.m3u"}}}, - } - Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ - criteria.InPlaylist{"path": filepath.FromSlash("../up.m3u")}, - })) - }) - - It("preserves every other criteria field", func() { - rules := criteria.Criteria{ - Expression: criteria.All{criteria.InPlaylist{"path": "child.nsp"}}, - Sort: "title", - Order: "desc", - Limit: 10, - LimitPercent: 25, - Offset: 5, - RefreshDelay: 3 * time.Hour, - } - pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &rules} - - normalized := *pls.NormalizedRules() - normalized.Expression = rules.Expression - Expect(normalized).To(Equal(rules)) - }) - - It("does not mutate the original playlist rules", func() { - original := criteria.All{criteria.InPlaylist{"path": "child.nsp"}} - pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &criteria.Criteria{Expression: original}} - _ = pls.NormalizedRules() - Expect(original[0]).To(BeEquivalentTo(criteria.InPlaylist{"path": "child.nsp"})) - }) - }) }) diff --git a/model/playqueue.go b/model/playqueue.go index ddc53d7da..03b562253 100644 --- a/model/playqueue.go +++ b/model/playqueue.go @@ -1,7 +1,6 @@ package model import ( - "context" "time" ) @@ -19,11 +18,11 @@ type PlayQueue struct { type PlayQueues []PlayQueue type PlayQueueRepository interface { - Store(ctx context.Context, queue *PlayQueue, colNames ...string) error + Store(queue *PlayQueue, colNames ...string) error // Retrieve returns the playqueue without loading the full MediaFiles // (Items only contain IDs) - Retrieve(ctx context.Context, userId string) (*PlayQueue, error) + Retrieve(userId string) (*PlayQueue, error) // RetrieveWithMediaFiles returns the playqueue with full MediaFiles loaded - RetrieveWithMediaFiles(ctx context.Context, userId string) (*PlayQueue, error) - Clear(ctx context.Context, userId string) error + RetrieveWithMediaFiles(userId string) (*PlayQueue, error) + Clear(userId string) error } diff --git a/model/plugin.go b/model/plugin.go index a448a9633..18d66e305 100644 --- a/model/plugin.go +++ b/model/plugin.go @@ -1,11 +1,6 @@ package model -import ( - "context" - "time" - - "github.com/deluan/rest" -) +import "time" type Plugin struct { ID string `structs:"id" json:"id"` @@ -27,11 +22,11 @@ type Plugin struct { type Plugins []Plugin type PluginRepository interface { - rest.Repository[Plugin] - ClearErrors(ctx context.Context) error - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Delete(ctx context.Context, id string) error - Get(ctx context.Context, id string) (*Plugin, error) - GetAll(ctx context.Context, options ...QueryOptions) (Plugins, error) - Put(ctx context.Context, p *Plugin) error + ResourceRepository + ClearErrors() error + CountAll(options ...QueryOptions) (int64, error) + Delete(id string) error + Get(id string) (*Plugin, error) + GetAll(options ...QueryOptions) (Plugins, error) + Put(p *Plugin) error } diff --git a/model/properties.go b/model/properties.go index 24b56db26..06bb9ebec 100644 --- a/model/properties.go +++ b/model/properties.go @@ -1,10 +1,8 @@ package model -import "context" - type PropertyRepository interface { - Put(ctx context.Context, id string, value string) error - Get(ctx context.Context, id string) (string, error) - Delete(ctx context.Context, id string) error - DefaultGet(ctx context.Context, id string, defaultValue string) (string, error) + Put(id string, value string) error + Get(id string) (string, error) + Delete(id string) error + DefaultGet(id string, defaultValue string) (string, error) } diff --git a/model/radio.go b/model/radio.go index a7e0e9218..86f27c24c 100644 --- a/model/radio.go +++ b/model/radio.go @@ -1,16 +1,12 @@ package model import ( - "context" "time" - "github.com/deluan/rest" "github.com/navidrome/navidrome/consts" ) type Radio struct { - ItemImage `structs:"-"` - ID string `structs:"id" json:"id"` StreamUrl string `structs:"stream_url" json:"streamUrl"` Name string `structs:"name" json:"name"` @@ -31,11 +27,10 @@ func (r Radio) UploadedImagePath() string { type Radios []Radio type RadioRepository interface { - rest.Repository[Radio] - rest.Persistable[Radio] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Exists(ctx context.Context, id string) (bool, error) - Get(ctx context.Context, id string) (*Radio, error) - GetAll(ctx context.Context, options ...QueryOptions) (Radios, error) - Put(ctx context.Context, u *Radio, colsToUpdate ...string) error + ResourceRepository + CountAll(options ...QueryOptions) (int64, error) + Delete(id string) error + Get(id string) (*Radio, error) + GetAll(options ...QueryOptions) (Radios, error) + Put(u *Radio, colsToUpdate ...string) error } diff --git a/model/radio_test.go b/model/radio_test.go index 6ae74c3f4..dc421454e 100644 --- a/model/radio_test.go +++ b/model/radio_test.go @@ -14,18 +14,19 @@ import ( var _ = Describe("Radio", func() { Describe("CoverArtID", func() { It("returns a radio artwork ID", func() { - r := model.Radio{ID: "rd-1", UpdatedAt: time.Now()} + now := time.Now() + r := model.Radio{ID: "rd-1", UpdatedAt: now} artID := r.CoverArtID() Expect(artID.Kind).To(Equal(model.KindRadioArtwork)) Expect(artID.ID).To(Equal("rd-1")) - Expect(artID.LastUpdate.IsZero()).To(BeTrue()) + Expect(artID.LastUpdate).To(Equal(now)) }) }) Describe("UploadedImagePath", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir("/data") + conf.Server.DataFolder = "/data" }) It("returns empty string when no image uploaded", func() { diff --git a/model/request/request.go b/model/request/request.go index 33c49f797..8d7919298 100644 --- a/model/request/request.go +++ b/model/request/request.go @@ -2,7 +2,6 @@ package request import ( "context" - "sync/atomic" "github.com/navidrome/navidrome/model" ) @@ -10,17 +9,15 @@ import ( type contextKey string const ( - User = contextKey("user") - Username = contextKey("username") - Client = contextKey("client") - Version = contextKey("version") - Player = contextKey("player") - Transcoding = contextKey("transcoding") - ClientUniqueId = contextKey("clientUniqueId") - ReverseProxyIp = contextKey("reverseProxyIp") - InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins - TokenEpochHolder = contextKey("tokenEpochHolder") - ServerAddress = contextKey("serverAddress") + User = contextKey("user") + Username = contextKey("username") + Client = contextKey("client") + Version = contextKey("version") + Player = contextKey("player") + Transcoding = contextKey("transcoding") + ClientUniqueId = contextKey("clientUniqueId") + ReverseProxyIp = contextKey("reverseProxyIp") + InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins ) var allKeys = []contextKey{ @@ -33,7 +30,6 @@ var allKeys = []contextKey{ ClientUniqueId, ReverseProxyIp, InternalAuth, - ServerAddress, } func WithUser(ctx context.Context, u model.User) context.Context { @@ -72,25 +68,6 @@ func WithInternalAuth(ctx context.Context, username string) context.Context { return context.WithValue(ctx, InternalAuth, username) } -// serverAddress is the public scheme and host the client used to reach this server, -// so code running without an http.Request can still build absolute URLs. -type serverAddress struct { - scheme string - host string -} - -func WithServerAddress(ctx context.Context, scheme, host string) context.Context { - return context.WithValue(ctx, ServerAddress, serverAddress{scheme: scheme, host: host}) -} - -func ServerAddressFrom(ctx context.Context) (scheme, host string, ok bool) { - a, ok := ctx.Value(ServerAddress).(serverAddress) - if !ok || a.host == "" { - return "", "", false - } - return a.scheme, a.host, true -} - func UserFrom(ctx context.Context) (model.User, bool) { v, ok := ctx.Value(User).(model.User) return v, ok @@ -148,32 +125,3 @@ func AddValues(ctx, requestCtx context.Context) context.Context { } return ctx } - -type tokenEpochHolder struct { - value atomic.Int64 -} - -// WithTokenEpochHolder installs a slot a handler can use to report a bumped token epoch -// back to middleware that has already returned from the handler's perspective. -func WithTokenEpochHolder(ctx context.Context) context.Context { - h := &tokenEpochHolder{} - h.value.Store(-1) - return context.WithValue(ctx, TokenEpochHolder, h) -} - -func SetTokenEpoch(ctx context.Context, epoch int) { - if h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder); ok { - h.value.Store(int64(epoch)) - } -} - -func TokenEpochFrom(ctx context.Context) (int, bool) { - h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder) - if !ok { - return 0, false - } - if v := h.value.Load(); v >= 0 { - return int(v), true - } - return 0, false -} diff --git a/model/request/request_suite_test.go b/model/request/request_suite_test.go deleted file mode 100644 index 643ca76d7..000000000 --- a/model/request/request_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package request - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// tests.Init is not used here: the tests package imports model/request, so importing it -// back would create an import cycle. -func TestRequest(t *testing.T) { - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Request Suite") -} diff --git a/model/request/request_test.go b/model/request/request_test.go deleted file mode 100644 index da819f9c0..000000000 --- a/model/request/request_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package request - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Token epoch holder", func() { - It("reports nothing when unset", func() { - ctx := WithTokenEpochHolder(context.TODO()) - _, ok := TokenEpochFrom(ctx) - Expect(ok).To(BeFalse()) - }) - - It("round-trips a value set by the handler", func() { - ctx := WithTokenEpochHolder(context.TODO()) - SetTokenEpoch(ctx, 7) - - epoch, ok := TokenEpochFrom(ctx) - Expect(ok).To(BeTrue()) - Expect(epoch).To(Equal(7)) - }) - - It("survives being wrapped in a derived context", func() { - ctx := WithTokenEpochHolder(context.TODO()) - SetTokenEpoch(context.WithValue(ctx, contextKey("unrelated"), 1), 3) - - epoch, ok := TokenEpochFrom(ctx) - Expect(ok).To(BeTrue()) - Expect(epoch).To(Equal(3)) - }) - - It("is a no-op with no holder installed", func() { - Expect(func() { SetTokenEpoch(context.TODO(), 5) }).ToNot(Panic()) - _, ok := TokenEpochFrom(context.TODO()) - Expect(ok).To(BeFalse()) - }) -}) - -var _ = Describe("Server address", func() { - It("reports nothing when unset", func() { - _, _, ok := ServerAddressFrom(context.TODO()) - Expect(ok).To(BeFalse()) - }) - - It("round-trips the scheme and host", func() { - ctx := WithServerAddress(context.TODO(), "https", "music.example.com") - - scheme, host, ok := ServerAddressFrom(ctx) - Expect(ok).To(BeTrue()) - Expect(scheme).To(Equal("https")) - Expect(host).To(Equal("music.example.com")) - }) - - It("reports nothing when the host is empty", func() { - ctx := WithServerAddress(context.TODO(), "https", "") - - _, _, ok := ServerAddressFrom(ctx) - Expect(ok).To(BeFalse()) - }) - - It("is carried over to a background context by AddValues", func() { - reqCtx := WithServerAddress(context.TODO(), "https", "music.example.com") - - _, host, ok := ServerAddressFrom(AddValues(context.Background(), reqCtx)) - Expect(ok).To(BeTrue()) - Expect(host).To(Equal("music.example.com")) - }) -}) diff --git a/model/scanner.go b/model/scanner.go index d22c3d0d6..54f81037c 100644 --- a/model/scanner.go +++ b/model/scanner.go @@ -2,20 +2,17 @@ package model import ( "context" - "errors" "fmt" "strconv" "strings" "time" ) -var ErrAlreadyScanning = errors.New("already scanning") - // ScanTarget represents a specific folder within a library to be scanned. // NOTE: This struct is used as a map key, so it should only contain comparable types. type ScanTarget struct { LibraryID int - FolderPath string // Path within the library (relative or absolute), or "" for entire library + FolderPath string // Relative path within the library, or "" for entire library } func (st ScanTarget) String() string { diff --git a/model/scrobble.go b/model/scrobble.go index b99d25b6a..e1567abc3 100644 --- a/model/scrobble.go +++ b/model/scrobble.go @@ -1,25 +1,13 @@ package model -import ( - "context" - "time" - - "github.com/deluan/rest" -) +import "time" type Scrobble struct { - ID int64 `structs:"id" json:"id"` - MediaFileID string `structs:"media_file_id" json:"mediaFileId"` - UserID string `json:"-"` - SubmissionTime int64 `structs:"submission_time" json:"submissionTime"` + MediaFileID string + UserID string + SubmissionTime time.Time } type ScrobbleRepository interface { - rest.Repository[Scrobble] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Get(ctx context.Context, id string) (*Scrobble, error) - GetAll(ctx context.Context, options ...QueryOptions) (Scrobbles, error) - RecordScrobble(ctx context.Context, mediaFileID string, submissionTime time.Time) error + RecordScrobble(mediaFileID string, submissionTime time.Time) error } - -type Scrobbles []Scrobble diff --git a/model/scrobble_buffer.go b/model/scrobble_buffer.go index dda8dfce3..c75a82853 100644 --- a/model/scrobble_buffer.go +++ b/model/scrobble_buffer.go @@ -1,9 +1,6 @@ package model -import ( - "context" - "time" -) +import "time" type ScrobbleEntry struct { ID string @@ -18,10 +15,9 @@ type ScrobbleEntry struct { type ScrobbleEntries []ScrobbleEntry type ScrobbleBufferRepository interface { - UserIDs(ctx context.Context, service string) ([]string, error) - Enqueue(ctx context.Context, service, userId, mediaFileId string, playTime time.Time) error - Next(ctx context.Context, service string, userId string) (*ScrobbleEntry, error) - Dequeue(ctx context.Context, entry *ScrobbleEntry) error - Length(ctx context.Context) (int64, error) - Discard(ctx context.Context, service string) error + UserIDs(service string) ([]string, error) + Enqueue(service, userId, mediaFileId string, playTime time.Time) error + Next(service string, userId string) (*ScrobbleEntry, error) + Dequeue(entry *ScrobbleEntry) error + Length() (int64, error) } diff --git a/model/searchable.go b/model/searchable.go index 6dba989c1..a64a0171c 100644 --- a/model/searchable.go +++ b/model/searchable.go @@ -1,7 +1,5 @@ package model -import "context" - type SearchableRepository[T any] interface { - Search(ctx context.Context, q string, options ...QueryOptions) (T, error) + Search(q string, options ...QueryOptions) (T, error) } diff --git a/model/share.go b/model/share.go index 952fd9759..ce0846d60 100644 --- a/model/share.go +++ b/model/share.go @@ -2,11 +2,9 @@ package model import ( "cmp" - "context" "strings" "time" - "github.com/deluan/rest" "github.com/navidrome/navidrome/utils/random" ) @@ -34,6 +32,9 @@ type Share struct { func (s Share) CoverArtID() ArtworkID { ids := strings.SplitN(s.ResourceIDs, ",", 2) + if len(ids) == 0 { + return ArtworkID{} + } switch s.ResourceType { case "album": return Album{ID: ids[0]}.CoverArtID() @@ -42,10 +43,6 @@ func (s Share) CoverArtID() ArtworkID { case "artist": return Artist{ID: ids[0]}.CoverArtID() } - // Tracks can be empty when they went missing or the owner lost access to their library. - if len(s.Tracks) == 0 { - return ArtworkID{} - } rnd := random.Int64N(len(s.Tracks)) return s.Tracks[rnd].CoverArtID() } @@ -58,10 +55,8 @@ func (s Share) ToM3U8() string { } type ShareRepository interface { - rest.Repository[Share] - rest.Persistable[Share] - Exists(ctx context.Context, id string) (bool, error) - Get(ctx context.Context, id string) (*Share, error) - GetAll(ctx context.Context, options ...QueryOptions) (Shares, error) - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Get(id string) (*Share, error) + GetAll(options ...QueryOptions) (Shares, error) + CountAll(options ...QueryOptions) (int64, error) } diff --git a/model/share_test.go b/model/share_test.go deleted file mode 100644 index b5e38af59..000000000 --- a/model/share_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package model_test - -import ( - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Share.CoverArtID", func() { - It("returns an empty artwork ID for a media file share with no visible tracks", func() { - s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1"} - Expect(s.CoverArtID()).To(Equal(model.ArtworkID{})) - }) - - It("picks a track's cover for a media file share", func() { - s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1", Tracks: model.MediaFiles{{ID: "mf-1"}}} - Expect(s.CoverArtID()).To(Equal(model.MediaFile{ID: "mf-1"}.CoverArtID())) - }) -}) diff --git a/model/tag.go b/model/tag.go index 60fa252dd..1f6b24d21 100644 --- a/model/tag.go +++ b/model/tag.go @@ -2,15 +2,13 @@ package model import ( "cmp" - "context" + "crypto/md5" "fmt" "slices" "strings" - "github.com/deluan/rest" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" - "github.com/zeebo/xxh3" ) type Tag struct { @@ -26,17 +24,13 @@ type TagList []Tag func (l TagList) GroupByFrequency() Tags { grouped := map[string]map[string]int{} values := map[string]string{} - firstSeen := map[string]int{} - for i, t := range l { + for _, t := range l { if m, ok := grouped[string(t.TagName)]; !ok { grouped[string(t.TagName)] = map[string]int{t.ID: 1} } else { m[t.ID]++ } values[t.ID] = t.TagValue - if _, ok := firstSeen[t.ID]; !ok { - firstSeen[t.ID] = i - } } tags := Tags{} @@ -48,7 +42,7 @@ func (l TagList) GroupByFrequency() Tags { slices.SortFunc(idList, func(a, b string) int { return cmp.Or( cmp.Compare(counts[b], counts[a]), - cmp.Compare(firstSeen[a], firstSeen[b]), + cmp.Compare(values[a], values[b]), ) }) tags[TagName(name)] = slice.Map(idList, func(id string) string { return values[id] }) @@ -82,13 +76,6 @@ func (t Tags) Values(name TagName) []string { return t[name] } -func (t Tags) First(name TagName) string { - if v := t[name]; len(v) > 0 { - return v[0] - } - return "" -} - func (t Tags) IDs() []string { var ids []string for name, tag := range t { @@ -130,7 +117,7 @@ func (t Tags) Hash() []byte { } ids := t.IDs() slices.Sort(ids) - sum := xxh3.New() + sum := md5.New() sum.Write([]byte(strings.Join(ids, "|"))) return sum.Sum(nil) } @@ -164,10 +151,8 @@ func (t Tags) Add(name TagName, v string) { } type TagRepository interface { - rest.Repository[Tag] - Add(ctx context.Context, libraryID int, tags ...Tag) error - UpdateCounts(ctx context.Context) error - GetAll(ctx context.Context, name TagName, options ...QueryOptions) (TagList, error) + Add(libraryID int, tags ...Tag) error + UpdateCounts() error } type TagName string @@ -207,10 +192,6 @@ 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 @@ -259,7 +240,6 @@ 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 5a8168754..bfe098f77 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -7,11 +7,9 @@ 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" @@ -28,155 +26,31 @@ 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:"-"` - ExceptionsRx *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:"-"` } -// SplitTagValue splits tag values by the configured split separators. -// Each value in the input slice is individually split and trimmed. +// SplitTagValue splits a tag value by the split separators, but only if it has a single value. func (c TagConf) SplitTagValue(values []string) []string { - if c.SplitRx == nil || len(values) == 0 { + // If there's not exactly one value or no separators, return early. + if len(values) != 1 || c.SplitRx == nil { return values } + tag := values[0] - var result []string - for _, tag := range values { - result = append(result, c.splitValue(tag)...) - } - return result -} + // Replace all occurrences of any separator with the zero-width space. + tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) -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] + // 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) } - 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 + return parts } type TagType string @@ -195,28 +69,6 @@ func TagMappings() map[TagName]TagConf { return mappings } -// CanonicalTagName returns the mapped tag that name is, or is an alias of. Tags are stored under this name. -func CanonicalTagName(name string) (TagName, bool) { - tagName, ok := tagNameIndex()[TagName(name).ToLower()] - return tagName, ok -} - -// tagNameIndex maps every tag name and alias to its tag name. Names are added last, so they win over aliases -// (musicbrainz_trackid is a tag and also an alias of musicbrainz_recordingid). -var tagNameIndex = sync.OnceValue(func() map[TagName]TagName { - mappings := TagMappings() - index := make(map[TagName]TagName, len(mappings)) - for name, tag := range mappings { - for _, alias := range tag.Aliases { - index[TagName(alias)] = name - } - } - for name := range mappings { - index[name] = name - } - return index -}) - func TagRolesConf() TagConf { _, cfg := parseMappings() return cfg.Roles diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go deleted file mode 100644 index 91e54e5d4..000000000 --- a/model/tag_mappings_test.go +++ /dev/null @@ -1,213 +0,0 @@ -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)) - } - }) - }) -}) - -var _ = Describe("CanonicalTagName", func() { - DescribeTable("resolves tag names and aliases", - func(name string, expected TagName) { - tagName, ok := CanonicalTagName(name) - Expect(ok).To(BeTrue()) - Expect(tagName).To(Equal(expected)) - }, - Entry("tag name", "album", TagAlbum), - Entry("alias", "talb", TagAlbum), - Entry("mixed case alias", "TALB", TagAlbum), - Entry("tag name that is also an alias of another tag", "musicbrainz_trackid", TagMusicBrainzTrackID), - ) - - It("does not resolve an unknown name", func() { - _, ok := CanonicalTagName("nosuchtag") - Expect(ok).To(BeFalse()) - }) -}) diff --git a/model/tag_test.go b/model/tag_test.go index c5a941b4e..c01aa0b4c 100644 --- a/model/tag_test.go +++ b/model/tag_test.go @@ -43,10 +43,6 @@ var _ = Describe("Tag", func() { Expect(tags.Values("genre")).To(ConsistOf("Rock", "Pop")) Expect(tags.Values("artist")).To(ConsistOf("The Beatles")) }) - It("should get the first value by name", func() { - Expect(tags.First("genre")).To(Equal("Rock")) - Expect(tags.First("missing")).To(BeEmpty()) - }) Describe("Hash", func() { It("should always return the same value for the same tags ", func() { @@ -97,7 +93,7 @@ var _ = Describe("Tag", func() { Expect(groupedTags).To(HaveKeyWithValue(TagName("artist"), []string{"The Beatles", "The Rolling Stones"})) }) - It("should keep the order the values appeared in when frequency is the same", func() { + It("should sort tags by name when frequency is the same", func() { tagList := TagList{ NewTag("genre", "Jazz"), NewTag("genre", "Rock"), @@ -107,7 +103,7 @@ var _ = Describe("Tag", func() { groupedTags := tagList.GroupByFrequency() - Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Jazz", "Rock", "Alternative", "Pop"})) + Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Alternative", "Jazz", "Pop", "Rock"})) }) It("should normalize casing", func() { tagList := TagList{ diff --git a/model/transcoding.go b/model/transcoding.go index da5e09e91..9b81a7c9c 100644 --- a/model/transcoding.go +++ b/model/transcoding.go @@ -1,11 +1,5 @@ package model -import ( - "context" - - "github.com/deluan/rest" -) - type Transcoding struct { ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` @@ -17,10 +11,8 @@ type Transcoding struct { type Transcodings []Transcoding type TranscodingRepository interface { - rest.Repository[Transcoding] - rest.Persistable[Transcoding] - Get(ctx context.Context, id string) (*Transcoding, error) - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Put(ctx context.Context, t *Transcoding) error - FindByFormat(ctx context.Context, format string) (*Transcoding, error) + Get(id string) (*Transcoding, error) + CountAll(...QueryOptions) (int64, error) + Put(*Transcoding) error + FindByFormat(format string) (*Transcoding, error) } diff --git a/model/user.go b/model/user.go index bbeb4a05d..1c8541ccf 100644 --- a/model/user.go +++ b/model/user.go @@ -1,10 +1,7 @@ package model import ( - "context" "time" - - "github.com/deluan/rest" ) type User struct { @@ -17,16 +14,12 @@ type User struct { LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"` CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` - // Smart-playlist criteria JSON; matching songs are not sent to external scrobblers - ScrobbleFilter string `structs:"scrobble_filter" json:"scrobbleFilter"` // Library associations (many-to-many relationship) Libraries Libraries `structs:"-" json:"libraries,omitempty"` // This is only available on the backend, and it is never sent over the wire Password string `structs:"-" json:"-"` - // Bumped on password change to invalidate every issued token for this user. - TokenEpoch int `structs:"-" json:"-"` // This is used to set or change a password when calling Put. If it is empty, the password is not changed. // It is received from the UI with the name "password" NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec @@ -49,21 +42,21 @@ func (u User) HasLibraryAccess(libraryID int) bool { type Users []User type UserRepository interface { - rest.Repository[User] - rest.Persistable[User] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Get(ctx context.Context, id string) (*User, error) - GetAll(ctx context.Context, options ...QueryOptions) (Users, error) - Put(ctx context.Context, u *User) error - UpdateLastLoginAt(ctx context.Context, id string) error - UpdateLastAccessAt(ctx context.Context, id string) error - FindFirstAdmin(ctx context.Context) (*User, error) + ResourceRepository + CountAll(...QueryOptions) (int64, error) + Delete(id string) error + Get(id string) (*User, error) + GetAll(options ...QueryOptions) (Users, error) + Put(*User) error + UpdateLastLoginAt(id string) error + UpdateLastAccessAt(id string) error + FindFirstAdmin() (*User, error) // FindByUsername must be case-insensitive - FindByUsername(ctx context.Context, username string) (*User, error) + FindByUsername(username string) (*User, error) // FindByUsernameWithPassword is the same as above, but also returns the decrypted password - FindByUsernameWithPassword(ctx context.Context, username string) (*User, error) + FindByUsernameWithPassword(username string) (*User, error) // Library association methods - GetUserLibraries(ctx context.Context, userID string) (Libraries, error) - SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error + GetUserLibraries(userID string) (Libraries, error) + SetUserLibraries(userID string, libraryIDs []int) error } diff --git a/model/user_props.go b/model/user_props.go index af19f34f1..c2eb536ec 100644 --- a/model/user_props.go +++ b/model/user_props.go @@ -1,10 +1,8 @@ package model -import "context" - type UserPropsRepository interface { - Put(ctx context.Context, userId, key string, value string) error - Get(ctx context.Context, userId, key string) (string, error) - Delete(ctx context.Context, userId, key string) error - DefaultGet(ctx context.Context, userId, key string, defaultValue string) (string, error) + Put(userId, key string, value string) error + Get(userId, key string) (string, error) + Delete(userId, key string) error + DefaultGet(userId, key string, defaultValue string) (string, error) } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6a940a320..99ed10877 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -3,7 +3,6 @@ package persistence import ( "context" "encoding/json" - "errors" "fmt" "iter" "maps" @@ -18,7 +17,6 @@ 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" ) @@ -32,10 +30,6 @@ type dbAlbum struct { Participants string `structs:"-" json:"-"` Tags string `structs:"-" json:"-"` FolderIDs string `structs:"-" json:"-"` - // dbx maps columns to fields by name; RGAlbumGain doesn't convert to - // rg_album_gain, so shim fields carry the read and PostScan copies them over. - RgAlbumGain *float64 `structs:"-" json:"-"` - RgAlbumPeak *float64 `structs:"-" json:"-"` } func (a *dbAlbum) PostScan() error { @@ -63,8 +57,6 @@ func (a *dbAlbum) PostScan() error { } a.Album.FolderIDs = ids } - a.Album.RGAlbumGain = a.RgAlbumGain - a.Album.RGAlbumPeak = a.RgAlbumPeak return nil } @@ -77,7 +69,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"] = str.NormalizeForFTS(a.Name, a.AlbumArtist) + args["search_normalized"] = normalizeForFTS(a.Name, a.AlbumArtist) args["tags"] = marshalTags(a.Album.Tags) args["participants"] = marshalParticipants(a.Album.Participants) @@ -102,8 +94,9 @@ func (as dbAlbums) toModels() model.Albums { return slice.Map(as, func(a dbAlbum) model.Album { return *a.Album }) } -func NewAlbumRepository(db dbx.Builder) model.AlbumRepository { +func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumRepository { r := &albumRepository{} + r.ctx = ctx r.db = db r.tableName = "album" r.registerModel(&model.Album{}, albumFilters()) @@ -112,7 +105,7 @@ func NewAlbumRepository(db dbx.Builder) model.AlbumRepository { "artist": "compilation, order_album_artist_name, order_album_name", "album_artist": "compilation, order_album_artist_name, order_album_name", // TODO Rename this to just year (or date) - "max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + naturalSort("album.name"), + "max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name", "random": "random", "recently_added": recentlyAddedSort(), "starred_at": "starred, starred_at", @@ -131,9 +124,8 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { "recently_played": recentlyPlayedFilter, "starred": annotationBoolFilter("starred"), "has_rating": annotationBoolFilter("rating"), - "played": annotationBoolFilter("play_count"), "missing": booleanFilter, - "genre_id": genreFilter(AlbumGenres), + "genre_id": tagIDFilter, "role_total_id": allRolesFilter, "library_id": libraryIdFilter, } @@ -151,9 +143,9 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { func recentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "album.updated_at, album.id" + return "datetime(album.updated_at)" } - return "album.created_at, album.id" + return "datetime(album.created_at)" } func recentlyPlayedFilter(string, any) Sqlizer { @@ -172,67 +164,67 @@ func yearFilter(_ string, value any) Sqlizer { } func artistFilter(_ string, value any) Sqlizer { - return ParticipantIDFilter("album", value, model.RoleAlbumArtist, model.RoleArtist) + return Or{ + Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}), + Exists("json_tree(participants, '$.artist')", Eq{"value": value}), + } } func artistRoleFilter(name string, value any) Sqlizer { roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id") // Check if the role name is valid. If not, return an invalid filter - role, ok := model.AllRoles[roleName] - if !ok { + if _, ok := model.AllRoles[roleName]; !ok { return Gt{"": nil} } - return ParticipantIDFilter("album", value, role) + return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value}) } func allRolesFilter(_ string, value any) Sqlizer { - return ParticipantIDFilter("album", value) + return Like{"participants": fmt.Sprintf(`%%"%s"%%`, value)} } -func (r *albumRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - query := r.newSelect(ctx) - query = r.applyLibraryFilter(ctx, query) - if filtersNeedAnnotation(r.applyFilters(query, options...)) { - query = r.withAnnotation(ctx, query, "album.id") - } - return r.count(ctx, query, options...) +func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) { + query := r.newSelect() + query = r.withAnnotation(query, "album.id") + query = r.applyLibraryFilter(query) + return r.count(query, options...) } -func (r *albumRepository) Exists(ctx context.Context, id string) (bool, error) { - // The exists() helper applies no library filter, so it would report rows the caller cannot see. - c, err := r.count(ctx, r.applyLibraryFilter(ctx, r.newSelect(ctx).Where(Eq{"album.id": id}))) - return c > 0, err +func (r *albumRepository) Exists(id string) (bool, error) { + return r.exists(Eq{"album.id": id}) } -func (r *albumRepository) Put(ctx context.Context, al *model.Album) error { +func (r *albumRepository) Put(al *model.Album) error { al.ImportedAt = time.Now() - id, err := r.put(ctx, al.ID, &dbAlbum{Album: al}) + id, err := r.put(al.ID, &dbAlbum{Album: al}) if err != nil { return err } al.ID = id - if err := r.updateParticipants(ctx, al.ID, al.Participants); err != nil { - return err + if len(al.Participants) > 0 { + if err = r.updateParticipants(al.ID, al.Participants); err != nil { + return err + } } - return r.updateTags(ctx, al.ID, al.Tags) + return nil } // TODO Move external metadata to a separated table -func (r *albumRepository) UpdateExternalInfo(ctx context.Context, al *model.Album) error { - _, err := r.put(ctx, al.ID, &dbAlbum{Album: al}, "description", "small_image_url", "medium_image_url", "large_image_url", "external_url", "external_info_updated_at") +func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { + _, err := r.put(al.ID, &dbAlbum{Album: al}, "description", "small_image_url", "medium_image_url", "large_image_url", "external_url", "external_info_updated_at") return err } -func (r *albumRepository) selectAlbum(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(ctx, options...).Columns("album.*", "library.path as library_path", "library.name as library_name"). +func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { + sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on album.library_id = library.id") - sql = r.withAnnotation(ctx, sql, "album.id") - return r.applyLibraryFilter(ctx, sql) + sql = r.withAnnotation(sql, "album.id") + return r.applyLibraryFilter(sql) } -func (r *albumRepository) Get(ctx context.Context, id string) (*model.Album, error) { - res, err := r.GetAll(ctx, model.QueryOptions{Filters: Eq{"album.id": id}}) +func (r *albumRepository) Get(id string) (*model.Album, error) { + res, err := r.GetAll(model.QueryOptions{Filters: Eq{"album.id": id}}) if err != nil { return nil, err } @@ -242,98 +234,19 @@ func (r *albumRepository) Get(ctx context.Context, id string) (*model.Album, err return &res[0], nil } -func (r *albumRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Albums, error) { - sq := r.selectAlbum(ctx, options...) +func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, error) { + sq := r.selectAlbum(options...) var res dbAlbums - err := r.queryAll(ctx, sq, &res) + err := r.queryAll(sq, &res) if err != nil { return nil, err } - albums := res.toModels() - r.hydrateArtwork(ctx, albums) - return albums, nil + return res.toModels(), nil } -func (r *albumRepository) hydrateArtwork(ctx context.Context, albums model.Albums) { - hydrateItems(ctx, r.db, model.KindAlbumArtwork, albums, - func(a *model.Album) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) -} - -// getAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding. -func (r *albumRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - sq := r.applyLibraryFilter(ctx, r.newSelect(ctx, options...).Columns("album.id")) - if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(ctx, sq, "album.id") - } - ids := []string{} - err := r.queryAllSlice(ctx, sq, &ids) - return ids, err -} - -// soleAlbumArtistFilter matches albums with exactly one album artist. The artist artwork -// resolver and the scanner's image-change enqueue must select the same albums. -var soleAlbumArtistFilter = Eq{"json_array_length(participants, '$.albumartist')": 1} - -// SoleAlbumArtistFilter matches the albums where the given artist is the only album artist. -// Matches by album-artist participation, not the deprecated album_artist_id column. -func SoleAlbumArtistFilter(artistID string) Sqlizer { - return And{ParticipantIDFilter("album", artistID, model.RoleAlbumArtist), soleAlbumArtistFilter} -} - -// GetSoleAlbumArtistIDsInSubtrees matches albums by their own folder_ids, which is the resolver's -// notion of an album's folders. -func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(ctx context.Context, lib model.Library, paths ...string) ([]string, error) { - if len(paths) == 0 { - return nil, nil - } - ids := []string{} - // Repeated IDs across chunks are fine: the queue upserts by PK. - for chunk := range slices.Chunk(paths, subtreePathChunkSize) { - inSubtree := Exists("json_each(album.folder_ids) je join folder on folder.id = je.value", - folderSubtreeFilter(lib, chunk)) - // Sole album artist, so participants[0] is the only one. - sq := Select("distinct json_extract(participants, '$.albumartist[0].id')").From("album"). - Where(And{soleAlbumArtistFilter, inSubtree}) - var chunkIDs []string - if err := r.queryAllSlice(ctx, sq, &chunkIDs); err != nil { - return nil, err - } - ids = append(ids, chunkIDs...) - } - return ids, nil -} - -func (r *albumRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.AlbumCursor, error) { - ids, err := r.getAllIDs(ctx, options...) - if err != nil { - return nil, err - } - opts := chunkOptions(options, "album.id") - return model.AlbumCursor(streamByIDs(ids, func(chunk []string) (model.Albums, error) { - return r.GetAll(ctx, opts(chunk)) - })), nil -} - -func (r *albumRepository) GetYears(ctx context.Context, libraryIDs ...int) ([]int, error) { - cond := And{Gt{"max_year": 0}, Eq{"missing": false}} - if len(libraryIDs) > 0 { - cond = append(cond, Eq{"library_id": libraryIDs}) - } - sq := r.applyLibraryFilter(ctx, Select("distinct max_year").From("album").Where(cond).OrderBy("max_year")) - years := []int{} - err := r.queryAllSlice(ctx, sq, &years) - if err != nil && !errors.Is(err, model.ErrNotFound) { - return nil, err - } - return years, nil -} - -func (r *albumRepository) CopyAttributes(ctx context.Context, fromID, toID string, columns ...string) error { - // Cast values to text so go-sqlite3 does not decode datetime columns as time.Time - // and reformat them as RFC3339 when written back. - sel := slice.Map(columns, func(c string) string { return fmt.Sprintf("cast(%[1]s as text) as %[1]s", c) }) +func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { var from dbx.NullStringMap - err := r.queryOne(ctx, Select(sel...).From(r.tableName).Where(Eq{"id": fromID}), &from) + err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) if err != nil { return fmt.Errorf("getting album to copy fields from: %w", err) } @@ -351,35 +264,35 @@ func (r *albumRepository) CopyAttributes(ctx context.Context, fromID, toID strin if len(to) == 0 { return nil } - _, err = r.executeSQL(ctx, Update(r.tableName).SetMap(to).Where(Eq{"id": toID})) + _, err = r.executeSQL(Update(r.tableName).SetMap(to).Where(Eq{"id": toID})) return err } // Touch flags an album as being scanned by the scanner, but not necessarily updated. // This is used for when missing tracks are detected for an album during scan. -func (r *albumRepository) Touch(ctx context.Context, ids ...string) error { +func (r *albumRepository) Touch(ids ...string) error { if len(ids) == 0 { return nil } for ids := range slices.Chunk(ids, 200) { upd := Update(r.tableName).Set("imported_at", time.Now()).Where(Eq{"id": ids}) - c, err := r.executeSQL(ctx, upd) + c, err := r.executeSQL(upd) if err != nil { return fmt.Errorf("error touching albums: %w", err) } - log.Debug(ctx, "Touching albums", "ids", ids, "updated", c) + log.Debug(r.ctx, "Touching albums", "ids", ids, "updated", c) } return nil } // TouchByMissingFolder touches all albums that have missing folders -func (r *albumRepository) TouchByMissingFolder(ctx context.Context) (int64, error) { +func (r *albumRepository) TouchByMissingFolder() (int64, error) { upd := Update(r.tableName).Set("imported_at", time.Now()). Where(And{ NotEq{"folder_ids": nil}, ConcatExpr("EXISTS (SELECT 1 FROM json_each(folder_ids) AS je JOIN main.folder AS f ON je.value = f.id WHERE f.missing = true)"), }) - c, err := r.executeSQL(ctx, upd) + c, err := r.executeSQL(upd) if err != nil { return 0, fmt.Errorf("error touching albums by missing folder: %w", err) } @@ -389,13 +302,13 @@ func (r *albumRepository) TouchByMissingFolder(ctx context.Context) (int64, erro // GetTouchedAlbums returns all albums that were touched by the scanner for a given library, in the // current library scan run. // It does not need to load participants, as they are not used by the scanner. -func (r *albumRepository) GetTouchedAlbums(ctx context.Context, libID int) (model.AlbumCursor, error) { - query := r.selectAlbum(ctx). +func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { + query := r.selectAlbum(). Where(And{ Eq{"library.id": libID}, ConcatExpr("album.imported_at > library.last_scan_at"), }) - cursor, err := queryWithStableResults[dbAlbum](ctx, r.sqlRepository, query) + cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, query) if err != nil { return nil, err } @@ -403,12 +316,22 @@ func (r *albumRepository) GetTouchedAlbums(ctx context.Context, libID int) (mode } func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor { - return model.AlbumCursor(wrapCursor(cursor, func(a dbAlbum) *model.Album { return a.Album })) + return func(yield func(model.Album, error) bool) { + for a, err := range cursor { + if a.Album == nil { + yield(model.Album{}, fmt.Errorf("unexpected nil album (%v): %w", a, err)) + return + } + if !yield(*a.Album, err) || err != nil { + return + } + } + } } // RefreshPlayCounts updates the play count and last play date annotations for all albums, based // on the media files associated with them. -func (r *albumRepository) RefreshPlayCounts(ctx context.Context) (int64, error) { +func (r *albumRepository) RefreshPlayCounts() (int64, error) { query := Expr(` with play_counts as ( select user_id, album_id, sum(play_count) as total_play_count, max(play_date) as last_play_date @@ -424,21 +347,21 @@ on conflict (user_id, item_id, item_type) do update set play_count = excluded.play_count, play_date = excluded.play_date; `) - return r.executeSQL(ctx, query) + return r.executeSQL(query) } -func (r *albumRepository) purgeEmpty(ctx context.Context, libraryIDs ...int) error { +func (r *albumRepository) purgeEmpty(libraryIDs ...int) error { del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)") // If libraryIDs are specified, only purge albums from those libraries if len(libraryIDs) > 0 { del = del.Where(Eq{"library_id": libraryIDs}) } - c, err := r.executeSQL(ctx, del) + c, err := r.executeSQL(del) if err != nil { return fmt.Errorf("purging empty albums: %w", err) } if c > 0 { - log.Debug(ctx, "Purged empty albums", "totalDeleted", c) + log.Debug(r.ctx, "Purged empty albums", "totalDeleted", c) } return nil } @@ -449,32 +372,38 @@ var albumSearchConfig = searchConfig{ MBIDFields: []string{"mbz_album_id", "mbz_release_group_id"}, } -func (r *albumRepository) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.Albums, error) { +func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model.Albums, error) { var opts model.QueryOptions if len(options) > 0 { opts = options[0] } var res dbAlbums - err := r.doSearch(ctx, r.selectAlbum(ctx, options...), q, &res, albumSearchConfig, opts) + err := r.doSearch(r.selectAlbum(options...), q, &res, albumSearchConfig, opts) if err != nil { return nil, fmt.Errorf("searching album %q: %w", q, err) } - albums := res.toModels() - r.hydrateArtwork(ctx, albums) - return albums, nil + return res.toModels(), nil } -func (r *albumRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *albumRepository) Read(ctx context.Context, id string) (*model.Album, error) { - return r.Get(ctx, id) +func (r *albumRepository) Read(id string) (any, error) { + return r.Get(id) } -func (r *albumRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Album, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *albumRepository) EntityName() string { + return "album" +} + +func (r *albumRepository) NewInstance() any { + return &model.Album{} } var _ model.AlbumRepository = (*albumRepository)(nil) -var _ rest.Repository[model.Album] = (*albumRepository)(nil) +var _ model.ResourceRepository = (*albumRepository)(nil) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 0235805c0..a6270933f 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -1,86 +1,32 @@ package persistence import ( - "context" "errors" "fmt" - "sort" - "sync" "time" "github.com/Masterminds/squirrel" "github.com/deluan/rest" "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/id" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// rawColumn returns a column exactly as stored, bypassing go-sqlite3's decoding of -// `datetime` columns into time.Time. -func rawColumn(ctx context.Context, r sqlRepository, id, column string) string { - var res struct{ Value string } - sel := squirrel.Select("cast(" + column + " as text) as value"). - From(r.tableName).Where(squirrel.Eq{"id": id}) - ExpectWithOffset(1, r.queryOne(ctx, sel, &res)).To(Succeed()) - return res.Value -} - var _ = Describe("AlbumRepository", func() { var albumRepo *albumRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) - albumRepo = NewAlbumRepository(GetDBXBuilder()).(*albumRepository) - }) - - Describe("natural sorting", func() { - var ids []string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ids = nil - for _, n := range []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"} { - aid := "nat-" + n - ids = append(ids, aid) - Expect(albumRepo.Put(ctx, &model.Album{ - ID: aid, LibraryID: 1, Name: n, OrderAlbumName: n, - })).To(Succeed()) - } - DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": ids})) - }) - }) - - DescribeTable("sorts albums by name", - func(naturalSorting, preferSortTags bool, expected []string) { - conf.Server.EnableNaturalSorting = naturalSorting - conf.Server.PreferSortTags = preferSortTags - albumRepo = NewAlbumRepository(GetDBXBuilder()).(*albumRepository) - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ - Sort: "name", Filters: squirrel.Eq{"album.id": ids}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(albums, func(a model.Album) string { return a.Name })).To(Equal(expected)) - }, - Entry("lexicographically by default", false, false, - []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"}), - Entry("by number value when natural sorting is enabled", true, false, - []string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}), - Entry("by number value with sort tags preferred too", true, true, - []string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}), - ) + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) + albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) }) Describe("Get", func() { var Get = func(id string) (*model.Album, error) { - album, err := albumRepo.Get(ctx, id) + album, err := albumRepo.Get(id) if album != nil { album.ImportedAt = time.Time{} } @@ -100,144 +46,30 @@ var _ = Describe("AlbumRepository", func() { BeforeEach(func() { srcTime = time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) dstTime = time.Date(2024, 6, 7, 8, 9, 10, 0, time.UTC) - Expect(albumRepo.Put(ctx, &model.Album{ID: "copy-src", Name: "src", LibraryID: 1, CreatedAt: srcTime})).To(Succeed()) - Expect(albumRepo.Put(ctx, &model.Album{ID: "copy-dst", Name: "dst", LibraryID: 1, CreatedAt: dstTime})).To(Succeed()) - Expect(albumRepo.Put(ctx, &model.Album{ID: "copy-zero", Name: "zero", LibraryID: 1})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{ID: "copy-src", Name: "src", LibraryID: 1, CreatedAt: srcTime})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{ID: "copy-dst", Name: "dst", LibraryID: 1, CreatedAt: dstTime})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{ID: "copy-zero", Name: "zero", LibraryID: 1})).To(Succeed()) DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"copy-src", "copy-dst", "copy-zero"}})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"copy-src", "copy-dst", "copy-zero"}})) }) }) It("copies a valid created_at from source to destination", func() { - Expect(albumRepo.CopyAttributes(ctx, "copy-src", "copy-dst", "created_at")).To(Succeed()) - got, err := albumRepo.Get(ctx, "copy-dst") + Expect(albumRepo.CopyAttributes("copy-src", "copy-dst", "created_at")).To(Succeed()) + got, err := albumRepo.Get("copy-dst") Expect(err).ToNot(HaveOccurred()) Expect(got.CreatedAt).To(BeTemporally("~", srcTime, time.Second)) }) It("leaves destination untouched when source created_at is zero", func() { - Expect(albumRepo.CopyAttributes(ctx, "copy-zero", "copy-dst", "created_at")).To(Succeed()) - got, err := albumRepo.Get(ctx, "copy-dst") + Expect(albumRepo.CopyAttributes("copy-zero", "copy-dst", "created_at")).To(Succeed()) + got, err := albumRepo.Get("copy-dst") Expect(err).ToNot(HaveOccurred()) Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) }) - It("returns not found and leaves destination untouched when source does not exist", func() { - err := albumRepo.CopyAttributes(ctx, "copy-missing", "copy-dst", "created_at") - Expect(errors.Is(err, model.ErrNotFound)).To(BeTrue()) - got, getErr := albumRepo.Get(ctx, "copy-dst") - Expect(getErr).ToNot(HaveOccurred()) - Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) - }) - It("keeps the copied created_at in the driver's space-separated format", func() { - // Copying through a Go string would rewrite it as RFC3339 ("2020-01-02T03:04:05Z"), - // which string-sorts above every space-format timestamp and pins the album to the - // top of "Recently Added". - Expect(albumRepo.CopyAttributes(ctx, "copy-src", "copy-dst", "created_at")).To(Succeed()) - Expect(rawColumn(ctx, albumRepo.sqlRepository, "copy-dst", "created_at")). - To(Equal(rawColumn(ctx, albumRepo.sqlRepository, "copy-src", "created_at"))) - Expect(rawColumn(ctx, albumRepo.sqlRepository, "copy-dst", "created_at")).ToNot(ContainSubstring("T")) - }) - }) - - Describe("GetCursor", func() { - It("yields the same albums as GetAll", func() { - opts := model.QueryOptions{Sort: "name"} - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(albumRepo.GetCursor(ctx, opts))).To(Equal([]model.Album(want))) - }) - - It("honors Max/Offset like GetAll", func() { - opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(albumRepo.GetCursor(ctx, opts))).To(Equal([]model.Album(want))) - }) - }) - - Describe("getAllIDs", func() { - It("returns the same id set as GetAll", func() { - want, err := albumRepo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - ids, err := albumRepo.getAllIDs(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) - }) - }) - - Describe("GetSoleAlbumArtistIDsInSubtrees", func() { - It("returns the sole album artists of albums with folders in the subtree", func() { - folderRepo := newFolderRepository(GetDBXBuilder()) - lib, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - inTree := model.NewFolder(*lib, "SubtreeAlbums/Artist") - outTree := model.NewFolder(*lib, "OtherTree/Artist") - Expect(folderRepo.Put(ctx, inTree)).To(Succeed()) - Expect(folderRepo.Put(ctx, outTree)).To(Succeed()) - - // album_artist_id is deliberately wrong: the artist must come from participants - inAl := model.Album{ID: "subtree-in-al", Name: "In", LibraryID: 1, AlbumArtistID: "999", FolderIDs: []string{inTree.ID}, - Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}} - outAl := model.Album{ID: "subtree-out-al", Name: "Out", LibraryID: 1, AlbumArtistID: "3", FolderIDs: []string{outTree.ID}, - Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistBeatles}}}} - duoAl := model.Album{ID: "subtree-duo-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "5", FolderIDs: []string{inTree.ID}, - Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistPunctuation}, {Artist: artistBeatles}}}} - for _, al := range []model.Album{inAl, outAl, duoAl} { - Expect(albumRepo.Put(ctx, &al)).To(Succeed()) - } - DeferCleanup(func() { - _, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id LIKE 'subtree-%'").Execute() - _, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'SubtreeAlbums%' OR path LIKE 'OtherTree%'").Execute() - }) - - ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(ctx, *lib, "SubtreeAlbums") - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf("2")) // sole artist in the subtree; the duo and the outside album are excluded - }) - - It("stays under SQLite's expression tree depth limit with many paths", func() { - lib, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - paths := make([]string, 200) - for i := range paths { - paths[i] = fmt.Sprintf("DepthProbe/Folder%d", i) - } - - _, err = albumRepo.GetSoleAlbumArtistIDsInSubtrees(ctx, *lib, paths...) - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns nothing when given no paths", func() { - lib, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(ctx, *lib) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(BeEmpty()) - }) - }) - - Describe("SoleAlbumArtistFilter", func() { - It("matches only albums where the artist is the sole album artist", func() { - // album_artist_id is deliberately wrong: matching must come from participation - sole := model.Album{ID: "sole-artist-al", Name: "Sole", LibraryID: 1, AlbumArtistID: "999", - Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}} - duo := model.Album{ID: "duo-artist-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "999", - Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}, {Artist: artistBeatles}}}} - Expect(albumRepo.Put(ctx, &sole)).To(Succeed()) - Expect(albumRepo.Put(ctx, &duo)).To(Succeed()) - DeferCleanup(func() { - _, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id IN ('sole-artist-al', 'duo-artist-al')").Execute() - }) - - als, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: SoleAlbumArtistFilter("2")}) - Expect(err).ToNot(HaveOccurred()) - Expect(als).To(HaveLen(1)) - Expect(als[0].ID).To(Equal(sole.ID)) - }) }) Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { - albums, err := albumRepo.GetAll(ctx, opts...) + albums, err := albumRepo.GetAll(opts...) for i := range albums { albums[i].ImportedAt = time.Time{} } @@ -280,66 +112,49 @@ var _ = Describe("AlbumRepository", func() { }) Describe("recently_added sort", func() { - AfterEach(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album"). - Where(squirrel.Like{"id": "ra-%"})) - }) + 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. - // 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 { + // 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()) + _, err := albumRepo.executeSQL(squirrel.Update("album"). + Set("created_at", "2024-01-15T08:00:00Z"). + Where(squirrel.Eq{"id": "ts-older"})) + 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"})) + Expect(err).ToNot(HaveOccurred()) + + albums, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) + Expect(err).ToNot(HaveOccurred()) + + // Find positions of our test albums + olderIdx, newerIdx := -1, -1 for i, a := range albums { - if a.ID == id { - return i + switch a.ID { + case "ts-older": + olderIdx = i + case "ts-newer": + newerIdx = i } } - return -1 - } + 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") - 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(ctx, earlier)).To(Succeed()) - Expect(albumRepo.Put(ctx, later)).To(Succeed()) - _, err := albumRepo.executeSQL(ctx, squirrel.Update("album"). - Set("created_at", "2024-01-15 10:00:00.100000000+00:00"). - Where(squirrel.Eq{"id": "ra-earlier"})) - Expect(err).ToNot(HaveOccurred()) - _, err = albumRepo.executeSQL(ctx, squirrel.Update("album"). - 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(ctx, 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") - }) - - 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(ctx, &model.Album{LibraryID: 1, ID: aid, Name: aid})).To(Succeed()) - } - _, err := albumRepo.executeSQL(ctx, squirrel.Update("album"). - Set("created_at", "2024-02-20 12:00:00+00:00"). - Where(squirrel.Eq{"id": ids})) - Expect(err).ToNot(HaveOccurred()) - - all, err := albumRepo.GetAll(ctx, model.QueryOptions{Sort: "recently_added", Order: "desc"}) - Expect(err).ToNot(HaveOccurred()) - - subset, err := albumRepo.GetAll(ctx, 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") + // Clean up + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"ts-older", "ts-newer"}})) }) }) @@ -349,20 +164,20 @@ var _ = Describe("AlbumRepository", func() { BeforeEach(func() { // Create album without any annotation (no star, no rating) albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1} - Expect(albumRepo.Put(ctx, &albumWithoutAnnotation)).To(Succeed()) + Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed()) }) AfterEach(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) }) Describe("starred", func() { It("false includes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ + res, err := albumRepo.ReadAll(rest.QueryOptions{ Filters: map[string]any{"starred": "false"}, }) Expect(err).ToNot(HaveOccurred()) - albums := res + albums := res.(model.Albums) var found bool for _, a := range albums { @@ -375,11 +190,11 @@ var _ = Describe("AlbumRepository", func() { }) It("true excludes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ + res, err := albumRepo.ReadAll(rest.QueryOptions{ Filters: map[string]any{"starred": "true"}, }) Expect(err).ToNot(HaveOccurred()) - albums := res + albums := res.(model.Albums) for _, a := range albums { Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID)) @@ -389,11 +204,11 @@ var _ = Describe("AlbumRepository", func() { Describe("has_rating", func() { It("false includes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ + res, err := albumRepo.ReadAll(rest.QueryOptions{ Filters: map[string]any{"has_rating": "false"}, }) Expect(err).ToNot(HaveOccurred()) - albums := res + albums := res.(model.Albums) var found bool for _, a := range albums { @@ -406,91 +221,17 @@ var _ = Describe("AlbumRepository", func() { }) It("true excludes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ + res, err := albumRepo.ReadAll(rest.QueryOptions{ Filters: map[string]any{"has_rating": "true"}, }) Expect(err).ToNot(HaveOccurred()) - albums := res + albums := res.(model.Albums) for _, a := range albums { Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID)) } }) }) - - Describe("played", func() { - var playedAlbum model.Album - - BeforeEach(func() { - playedAlbum = model.Album{ID: "played-album", Name: "Played Album", LibraryID: 1, SongCount: 1} - Expect(albumRepo.Put(ctx, &playedAlbum)).To(Succeed()) - Expect(albumRepo.IncPlayCount(ctx, playedAlbum.ID, time.Now())).To(Succeed()) - }) - - AfterEach(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": playedAlbum.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": playedAlbum.ID})) - }) - - It("false includes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"played": "false"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - var found bool - for _, a := range albums { - if a.ID == albumWithoutAnnotation.ID { - found = true - break - } - } - Expect(found).To(BeTrue(), "Album without annotation should be included in played=false filter") - }) - - It("true excludes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"played": "true"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - for _, a := range albums { - Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID)) - } - }) - - It("true includes items with play count", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"played": "true"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - var found bool - for _, a := range albums { - if a.ID == playedAlbum.ID { - found = true - Expect(a.PlayCount).To(BeNumerically(">", 0)) - break - } - } - Expect(found).To(BeTrue(), "Album with play count should be included in played=true filter") - }) - - It("false excludes items with play count", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"played": "false"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - for _, a := range albums { - Expect(a.ID).ToNot(Equal(playedAlbum.ID)) - } - }) - }) }) Describe("Album.PlayCount", func() { @@ -500,12 +241,12 @@ var _ = Describe("AlbumRepository", func() { conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeAbsolute newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) for range playCount { - Expect(albumRepo.IncPlayCount(ctx, newID, time.Now())).To(Succeed()) + Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed()) } - album, err := albumRepo.Get(ctx, newID) + album, err := albumRepo.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(album.PlayCount).To(Equal(int64(expected))) }, @@ -523,12 +264,12 @@ var _ = Describe("AlbumRepository", func() { conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeNormalized newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) for range playCount { - Expect(albumRepo.IncPlayCount(ctx, newID, time.Now())).To(Succeed()) + Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed()) } - album, err := albumRepo.Get(ctx, newID) + album, err := albumRepo.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(album.PlayCount).To(Equal(int64(expected))) }, @@ -545,79 +286,83 @@ var _ = Describe("AlbumRepository", func() { Describe("Album.AverageRating", func() { It("returns 0 when no ratings exist", func() { newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "no ratings album"})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "no ratings album"})).To(Succeed()) - album, err := albumRepo.Get(ctx, newID) + album, err := albumRepo.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(album.AverageRating).To(Equal(0.0)) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) }) It("returns the user's rating as average when only one user rated", func() { newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "single rating album"})).To(Succeed()) - Expect(albumRepo.SetRating(ctx, 4, newID)).To(Succeed()) + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "single rating album"})).To(Succeed()) + Expect(albumRepo.SetRating(4, newID)).To(Succeed()) - album, err := albumRepo.Get(ctx, newID) + album, err := albumRepo.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(album.AverageRating).To(Equal(4.0)) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) }) It("calculates average across multiple users", func() { newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "multi rating album"})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "multi rating album"})).To(Succeed()) - Expect(albumRepo.SetRating(ctx, 4, newID)).To(Succeed()) + Expect(albumRepo.SetRating(4, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - Expect(albumRepo.SetRating(user2Ctx, 5, newID)).To(Succeed()) + user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository) + Expect(user2Repo.SetRating(5, newID)).To(Succeed()) - album, err := albumRepo.Get(ctx, newID) + album, err := albumRepo.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(album.AverageRating).To(Equal(4.5)) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) }) It("excludes zero ratings from average calculation", func() { newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "zero rating excluded album"})).To(Succeed()) - Expect(albumRepo.SetRating(ctx, 3, newID)).To(Succeed()) + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "zero rating excluded album"})).To(Succeed()) + Expect(albumRepo.SetRating(3, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - Expect(albumRepo.SetRating(user2Ctx, 0, newID)).To(Succeed()) + user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository) + Expect(user2Repo.SetRating(0, newID)).To(Succeed()) - album, err := albumRepo.Get(ctx, newID) + album, err := albumRepo.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(album.AverageRating).To(Equal(3.0)) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) }) It("rounds to 2 decimal places", func() { newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "rounding test album"})).To(Succeed()) + Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "rounding test album"})).To(Succeed()) - Expect(albumRepo.SetRating(ctx, 5, newID)).To(Succeed()) + Expect(albumRepo.SetRating(5, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - Expect(albumRepo.SetRating(user2Ctx, 4, newID)).To(Succeed()) + user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository) + Expect(user2Repo.SetRating(4, newID)).To(Succeed()) user3Ctx := request.WithUser(GinkgoT().Context(), thirdUser) - Expect(albumRepo.SetRating(user3Ctx, 4, newID)).To(Succeed()) + user3Repo := NewAlbumRepository(user3Ctx, GetDBXBuilder()).(*albumRepository) + Expect(user3Repo.SetRating(4, newID)).To(Succeed()) - album, err := albumRepo.Get(ctx, newID) + album, err := albumRepo.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(album.AverageRating).To(Equal(4.33)) // (5 + 4 + 4) / 3 = 4.333... - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) }) }) @@ -736,16 +481,19 @@ var _ = Describe("AlbumRepository", func() { Describe("artistRoleFilter", func() { DescribeTable("creates correct SQL expressions for artist roles", - func(filterName, artistID, expectedRole string) { + func(filterName, artistID, expectedSQL string) { sqlizer := artistRoleFilter(filterName, artistID) sql, args, err := sqlizer.ToSql() Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("album.id IN (SELECT album_id FROM album_artists WHERE artist_id = ? AND role IN (?))")) - Expect(args).To(Equal([]any{artistID, expectedRole})) + Expect(sql).To(Equal(expectedSQL)) + Expect(args).To(Equal([]any{artistID})) }, - Entry("artist role", "role_artist_id", "123", "artist"), - Entry("albumartist role", "role_albumartist_id", "456", "albumartist"), - Entry("composer role", "role_composer_id", "789", "composer"), + Entry("artist role", "role_artist_id", "123", + "exists (select 1 from json_tree(participants, '$.artist') where value = ?)"), + Entry("albumartist role", "role_albumartist_id", "456", + "exists (select 1 from json_tree(participants, '$.albumartist') where value = ?)"), + Entry("composer role", "role_composer_id", "789", + "exists (select 1 from json_tree(participants, '$.composer') where value = ?)"), ) It("works with the actual filter map", func() { @@ -759,8 +507,8 @@ var _ = Describe("AlbumRepository", func() { sqlizer := filterFunc(filterName, "test-id") sql, args, err := sqlizer.ToSql() Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("album.id IN (SELECT album_id FROM album_artists WHERE artist_id = ? AND role IN (?))")) - Expect(args).To(Equal([]any{"test-id", roleName})) + Expect(sql).To(Equal(fmt.Sprintf("exists (select 1 from json_tree(participants, '$.%s') where value = ?)", roleName))) + Expect(args).To(Equal([]any{"test-id"})) } }) @@ -786,11 +534,10 @@ var _ = Describe("AlbumRepository", func() { } var artistRepo *artistRepository - var artistCtx context.Context BeforeEach(func() { - artistCtx = request.WithUser(ctx, adminUser) - artistRepo = NewArtistRepository(GetDBXBuilder()).(*artistRepository) + ctx := request.WithUser(GinkgoT().Context(), adminUser) + artistRepo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) }) // Helper to verify album_artists records @@ -802,7 +549,7 @@ var _ = Describe("AlbumRepository", func() { Where(squirrel.Eq{"album_id": albumID}). OrderBy("role", "artist_id", "sub_role") - err := albumRepo.queryAll(ctx, sq, &actual) + err := albumRepo.queryAll(sq, &actual) Expect(err).ToNot(HaveOccurred()) Expect(actual).To(Equal(expected)) } @@ -815,7 +562,7 @@ var _ = Describe("AlbumRepository", func() { OrderArtistName: "real artist", SortArtistName: "Artist, Real", } - err := createArtistWithLibrary(artistCtx, artistRepo, artist, 1) + err := createArtistWithLibrary(artistRepo, artist, 1) Expect(err).ToNot(HaveOccurred()) // Create an album with participants that reference the real artist @@ -836,7 +583,7 @@ var _ = Describe("AlbumRepository", func() { } // Insert the album - err = albumRepo.Put(ctx, album) + err = albumRepo.Put(album) Expect(err).ToNot(HaveOccurred()) // Verify that participant records were actually inserted into album_artists table @@ -847,76 +594,8 @@ var _ = Describe("AlbumRepository", func() { verifyAlbumArtists(album.ID, expected) // Clean up the test artist and album created for this test - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) - }) - - It("finds albums through the participant-based filters", func() { - artist := &model.Artist{ID: "filter-artist-1", Name: "Filter Artist", OrderArtistName: "filter artist"} - Expect(createArtistWithLibrary(artistCtx, artistRepo, artist, 1)).To(Succeed()) - - album := &model.Album{ - LibraryID: 1, - ID: "filter-album-1", - Name: "Filter Album", - AlbumArtistID: artist.ID, - AlbumArtist: artist.Name, - Participants: model.Participants{ - model.RoleAlbumArtist: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, - model.RoleComposer: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, - }, - } - Expect(albumRepo.Put(ctx, album)).To(Succeed()) - - byArtist, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) - Expect(err).ToNot(HaveOccurred()) - Expect(byArtist).To(HaveLen(1)) - Expect(byArtist[0].ID).To(Equal(album.ID)) - - byComposer, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: artistRoleFilter("role_composer_id", artist.ID)}) - Expect(err).ToNot(HaveOccurred()) - Expect(byComposer).To(HaveLen(1)) - - byLyricist, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: artistRoleFilter("role_lyricist_id", artist.ID)}) - Expect(err).ToNot(HaveOccurred()) - Expect(byLyricist).To(BeEmpty()) - - byAnyRole, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: allRolesFilter("role_total_id", artist.ID)}) - Expect(err).ToNot(HaveOccurred()) - Expect(byAnyRole).To(HaveLen(1)) - - count, err := albumRepo.CountAll(ctx, model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(1))) - - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) - }) - - It("clears album_artists rows when saved with empty participants", func() { - artist := &model.Artist{ID: "clear-artist-1", Name: "Clear Artist", OrderArtistName: "clear artist"} - Expect(createArtistWithLibrary(artistCtx, artistRepo, artist, 1)).To(Succeed()) - - album := &model.Album{ - LibraryID: 1, - ID: "clear-album-1", - Name: "Clear Album", - AlbumArtistID: artist.ID, - AlbumArtist: artist.Name, - Participants: model.Participants{ - model.RoleAlbumArtist: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, - }, - } - DeferCleanup(func() { - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) - }) - Expect(albumRepo.Put(ctx, album)).To(Succeed()) - verifyAlbumArtists(album.ID, []albumArtistRecord{{ArtistID: artist.ID, Role: "albumartist", SubRole: ""}}) - - album.Participants = model.Participants{} - Expect(albumRepo.Put(ctx, album)).To(Succeed()) - verifyAlbumArtists(album.ID, []albumArtistRecord{}) + _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) It("filters out invalid artist IDs leaving only valid participants in database", func() { @@ -931,9 +610,9 @@ var _ = Describe("AlbumRepository", func() { Name: "Real Artist 2", OrderArtistName: "real artist 2", } - err := createArtistWithLibrary(artistCtx, artistRepo, artist1, 1) + err := createArtistWithLibrary(artistRepo, artist1, 1) Expect(err).ToNot(HaveOccurred()) - err = createArtistWithLibrary(artistCtx, artistRepo, artist2, 1) + err = createArtistWithLibrary(artistRepo, artist2, 1) Expect(err).ToNot(HaveOccurred()) // Create an album with mix of valid and invalid artist IDs @@ -957,7 +636,7 @@ var _ = Describe("AlbumRepository", func() { } // This should not fail - only valid artists should be inserted - err = albumRepo.Put(ctx, album) + err = albumRepo.Put(album) Expect(err).ToNot(HaveOccurred()) // Verify that only valid artist IDs were inserted into album_artists table @@ -971,8 +650,8 @@ var _ = Describe("AlbumRepository", func() { // Clean up the test artists and album created for this test artistIDs := []string{artist1.ID, artist2.ID} - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) It("handles complex nested JSON with multiple roles and sub-roles", func() { @@ -985,7 +664,7 @@ var _ = Describe("AlbumRepository", func() { } for _, artist := range artists { - err := createArtistWithLibrary(artistCtx, artistRepo, artist, 1) + err := createArtistWithLibrary(artistRepo, artist, 1) Expect(err).ToNot(HaveOccurred()) } @@ -1012,7 +691,7 @@ var _ = Describe("AlbumRepository", func() { }, } - err := albumRepo.Put(ctx, album) + err := albumRepo.Put(album) Expect(err).ToNot(HaveOccurred()) // Verify complex JSON structure was correctly parsed and inserted @@ -1031,8 +710,8 @@ var _ = Describe("AlbumRepository", func() { for i, artist := range artists { artistIDs[i] = artist.ID } - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) It("handles albums with non-existent artist IDs without constraint errors", func() { @@ -1063,7 +742,7 @@ var _ = Describe("AlbumRepository", func() { // This should not fail with foreign key constraint error // The updateParticipants method should handle non-existent artist IDs gracefully - err := albumRepo.Put(ctx, album) + err := albumRepo.Put(album) Expect(err).ToNot(HaveOccurred()) // Verify that no participant records were inserted since all artist IDs were invalid @@ -1071,7 +750,7 @@ var _ = Describe("AlbumRepository", func() { verifyAlbumArtists(album.ID, []albumArtistRecord{}) // Clean up the test album created for this test - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) It("removes stale role associations when artist role changes", func() { @@ -1085,7 +764,7 @@ var _ = Describe("AlbumRepository", func() { Name: "Role Change Artist", OrderArtistName: "role change artist", } - err := createArtistWithLibrary(artistCtx, artistRepo, artist, 1) + err := createArtistWithLibrary(artistRepo, artist, 1) Expect(err).ToNot(HaveOccurred()) // Create album with artist as both albumartist and composer @@ -1105,7 +784,7 @@ var _ = Describe("AlbumRepository", func() { }, } - err = albumRepo.Put(ctx, album) + err = albumRepo.Put(album) Expect(err).ToNot(HaveOccurred()) // Verify initial state: artist has both albumartist and composer roles @@ -1122,7 +801,7 @@ var _ = Describe("AlbumRepository", func() { }, } - err = albumRepo.Put(ctx, album) + err = albumRepo.Put(album) Expect(err).ToNot(HaveOccurred()) // Verify that the albumartist role was removed - only composer should remain @@ -1134,67 +813,8 @@ var _ = Describe("AlbumRepository", func() { verifyAlbumArtists(album.ID, expectedAfter) // Clean up - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) - }) - }) - - Describe("GetYears", func() { - It("returns distinct album years ascending, excluding zero", func() { - years, err := albumRepo.GetYears(ctx) - Expect(err).ToNot(HaveOccurred()) - // Sorted ascending, no duplicates, no zero-year entries. - Expect(sort.IsSorted(sort.IntSlice(years))).To(BeTrue()) - Expect(years).ToNot(ContainElement(0)) - for i := 1; i < len(years); i++ { - Expect(years[i]).To(BeNumerically(">", years[i-1])) // strictly increasing = distinct - } - }) - - It("deduplicates repeated years", func() { - // Regression test: verify that DISTINCT is applied in the SQL. - // Insert two albums with the same non-zero max_year (2005). - album1 := &model.Album{LibraryID: 1, ID: "dedup-test-1", Name: "Album 1", MaxYear: 2005} - album2 := &model.Album{LibraryID: 1, ID: "dedup-test-2", Name: "Album 2", MaxYear: 2005} - Expect(albumRepo.Put(ctx, album1)).To(Succeed()) - Expect(albumRepo.Put(ctx, album2)).To(Succeed()) - DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}})) - }) - - years, err := albumRepo.GetYears(ctx) - Expect(err).ToNot(HaveOccurred()) - - // Count occurrences of 2005 in the result - count := 0 - for _, y := range years { - if y == 2005 { - count++ - } - } - Expect(count).To(Equal(1), "year 2005 should appear exactly once despite two albums having it") - }) - - It("scopes years to the given libraries", func() { - all, err := albumRepo.GetYears(ctx) - Expect(err).ToNot(HaveOccurred()) - // A library with no albums yields no years. - scoped, err := albumRepo.GetYears(ctx, 99999) - Expect(err).ToNot(HaveOccurred()) - Expect(scoped).To(BeEmpty()) - Expect(all).ToNot(BeEmpty()) - }) - - It("excludes years that belong only to missing albums", func() { - gone := &model.Album{LibraryID: 1, ID: "missing-year-1", Name: "Gone", MaxYear: 1911, Missing: true} - Expect(albumRepo.Put(ctx, gone)).To(Succeed()) - DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"})) - }) - - years, err := albumRepo.GetYears(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(years).ToNot(ContainElement(1911)) + _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) }) @@ -1217,7 +837,7 @@ var _ = Describe("AlbumRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Album")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil album")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) @@ -1237,79 +857,6 @@ var _ = Describe("AlbumRepository", func() { Expect(albums[0].ID).To(Equal("a1")) }) }) - - Describe("ReplayGain", func() { - BeforeEach(func() { - DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}})) - }) - }) - It("round-trips album ReplayGain gain and peak", func() { - Expect(albumRepo.Put(ctx, &model.Album{ - ID: "rg-1", Name: "rg", LibraryID: 1, - RGAlbumGain: new(-7.5), RGAlbumPeak: new(0.98), - })).To(Succeed()) - got, err := albumRepo.Get(ctx, "rg-1") - Expect(err).ToNot(HaveOccurred()) - Expect(got.RGAlbumGain).ToNot(BeNil()) - Expect(*got.RGAlbumGain).To(Equal(-7.5)) - Expect(got.RGAlbumPeak).ToNot(BeNil()) - Expect(*got.RGAlbumPeak).To(Equal(0.98)) - }) - It("reads nil when ReplayGain is unset", func() { - Expect(albumRepo.Put(ctx, &model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed()) - got, err := albumRepo.Get(ctx, "rg-2") - Expect(err).ToNot(HaveOccurred()) - Expect(got.RGAlbumGain).To(BeNil()) - Expect(got.RGAlbumPeak).To(BeNil()) - }) - }) - - // Exists must apply the same library filter as Get/GetAll/CountAll. - Describe("Exists library visibility", func() { - It("hides an album the user has no library access to", func() { - Expect(albumRepo.Put(ctx, &model.Album{ID: "vis-album", Name: "Vis", LibraryID: 1})).To(Succeed()) - DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": "vis-album"})) - }) - - Expect(albumRepo.Exists(ctx, "vis-album")).To(BeTrue(), "admin sees it") - - restricted := model.User{ID: "restricted_album_user", UserName: "ra", Name: "RA", Email: "ra@t.com"} - rctx := request.WithUser(GinkgoT().Context(), restricted) - Expect(albumRepo.Exists(rctx, "vis-album")).To(BeFalse()) - }) - - It("keeps per-user library visibility separate on a shared repository", func() { - adminCtx := request.WithUser(ctx, adminUser) - adminCount, err := albumRepo.CountAll(adminCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(adminCount).To(BeNumerically(">", 0)) - - // A user with no library grants, so its visibility can't drift with other specs - restrictedCtx := request.WithUser(ctx, model.User{ID: "shared-repo-restricted"}) - restrictedCount, err := albumRepo.CountAll(restrictedCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(restrictedCount).To(BeZero()) - - var wg sync.WaitGroup - for i := range 20 { - wg.Add(1) - go func(i int) { - defer GinkgoRecover() - defer wg.Done() - c, want := adminCtx, adminCount - if i%2 == 1 { - c, want = restrictedCtx, restrictedCount - } - got, err := albumRepo.CountAll(c) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(Equal(want)) - }(i) - } - wg.Wait() - }) - }) }) func _p(id, name string, sortName ...string) model.Participant { diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b90a48c48..e75a0e58c 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "maps" "os" "slices" "strings" @@ -19,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" ) @@ -104,10 +103,8 @@ 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"] = str.NormalizeForFTS(a.Name) + m["search_normalized"] = 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? @@ -130,8 +127,9 @@ func (dba dbArtists) toModels() model.Artists { return res } -func NewArtistRepository(db dbx.Builder) model.ArtistRepository { +func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistRepository { r := &artistRepository{} + r.ctx = ctx r.db = db r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups) r.tableName = "artist" // To be used by the idFilter below @@ -162,34 +160,21 @@ func NewArtistRepository(db dbx.Builder) model.ArtistRepository { func roleFilter(_ string, role any) Sqlizer { if role, ok := role.(string); ok { - if safe, ok := sanitizeArtistStatsRole(role); ok && safe != "total" { - return Expr("JSON_EXTRACT(library_artist.stats, '$." + safe + ".m') IS NOT NULL") + if _, ok := model.AllRoles[role]; ok { + return Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL") } } return Eq{"1": 2} } -// sanitizeArtistStatsRole allowlists values interpolated into JSON paths for artist -// stats (filter and sort). "total" is the aggregate key stored by the scanner. -// Unknown values must not reach SQL string concatenation. -func sanitizeArtistStatsRole(role string) (string, bool) { - if role == "" || role == "total" { - return "total", true - } - if _, ok := model.AllRoles[role]; ok { - return role, true - } - return "", false -} - // artistLibraryIdFilter filters artists based on library access through the library_artist table func artistLibraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_artist.library_id": value} } // applyLibraryFilterToArtistQuery applies library filtering to artist queries through the library_artist junction table -func (r *artistRepository) applyLibraryFilterToArtistQuery(ctx context.Context, query SelectBuilder) SelectBuilder { - user := loggedUser(ctx) +func (r *artistRepository) applyLibraryFilterToArtistQuery(query SelectBuilder) SelectBuilder { + user := loggedUser(r.ctx) // Join with library_artist first to ensure only artists with content in libraries are included // Exclude artists with empty stats (no actual content in the library) query = query.Join("library_artist on library_artist.artist_id = artist.id") @@ -204,109 +189,74 @@ func (r *artistRepository) applyLibraryFilterToArtistQuery(ctx context.Context, return query } -func (r *artistRepository) selectArtist(ctx context.Context, options ...model.QueryOptions) SelectBuilder { +func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder { // Stats Format: {"1": {"albumartist": {"m": 10, "a": 5, "s": 1024}, "artist": {...}}, "2": {...}} - query := r.newSelect(ctx, options...).Columns("artist.*", + query := r.newSelect(options...).Columns("artist.*", "JSON_GROUP_OBJECT(library_artist.library_id, JSONB(library_artist.stats)) as library_stats_json") - query = r.applyLibraryFilterToArtistQuery(ctx, query) + query = r.applyLibraryFilterToArtistQuery(query) query = query.GroupBy("artist.id") - return r.withAnnotation(ctx, query, "artist.id") + return r.withAnnotation(query, "artist.id") } -func (r *artistRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - query := r.newSelect(ctx) - query = r.applyLibraryFilterToArtistQuery(ctx, query) - // 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(ctx, query, "artist.id") - } - return r.count(ctx, query, options...) +func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) { + query := r.newSelect() + query = r.applyLibraryFilterToArtistQuery(query) + query = r.withAnnotation(query, "artist.id") + return r.count(query, options...) } // Exists checks if an artist with the given ID exists in the database and is accessible by the current user. -func (r *artistRepository) Exists(ctx context.Context, id string) (bool, error) { +func (r *artistRepository) Exists(id string) (bool, error) { // Create a query using the same library filtering logic as selectArtist() - query := r.newSelect(ctx).Columns("count(distinct artist.id) as exist").Where(Eq{"artist.id": id}) - query = r.applyLibraryFilterToArtistQuery(ctx, query) + query := r.newSelect().Columns("count(distinct artist.id) as exist").Where(Eq{"artist.id": id}) + query = r.applyLibraryFilterToArtistQuery(query) var res struct{ Exist int64 } - err := r.queryOne(ctx, query, &res) + err := r.queryOne(query, &res) return res.Exist > 0, err } -func (r *artistRepository) Put(ctx context.Context, a *model.Artist, colsToUpdate ...string) error { +func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { dba := &dbArtist{Artist: a} - dba.CreatedAt = new(time.Now()) + dba.CreatedAt = P(time.Now()) dba.UpdatedAt = dba.CreatedAt - _, err := r.put(ctx, dba.ID, dba, colsToUpdate...) + _, err := r.put(dba.ID, dba, colsToUpdate...) return err } -func (r *artistRepository) UpdateExternalInfo(ctx context.Context, a *model.Artist) error { +func (r *artistRepository) UpdateExternalInfo(a *model.Artist) error { dba := &dbArtist{Artist: a} - _, err := r.put(ctx, a.ID, dba, + _, err := r.put(a.ID, dba, "biography", "small_image_url", "medium_image_url", "large_image_url", "similar_artists", "external_url", "external_info_updated_at") return err } -func (r *artistRepository) Get(ctx context.Context, id string) (*model.Artist, error) { - sel := r.selectArtist(ctx).Where(Eq{"artist.id": id}) +func (r *artistRepository) Get(id string) (*model.Artist, error) { + sel := r.selectArtist().Where(Eq{"artist.id": id}) var dba dbArtists - if err := r.queryAll(ctx, sel, &dba); err != nil { + if err := r.queryAll(sel, &dba); err != nil { return nil, err } if len(dba) == 0 { return nil, model.ErrNotFound } res := dba.toModels() - r.hydrateArtwork(ctx, res) return &res[0], nil } -func (r *artistRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Artists, error) { - sel := r.selectArtist(ctx, options...) +func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) { + sel := r.selectArtist(options...) var dba dbArtists - err := r.queryAll(ctx, sel, &dba) + err := r.queryAll(sel, &dba) if err != nil { return nil, err } res := dba.toModels() - r.hydrateArtwork(ctx, res) return res, err } -// getAllIDs returns just the artist IDs for the same row set as GetAll, skipping the -// heavy stats columns and JSON post-processing. -func (r *artistRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - sq := r.applyLibraryFilterToArtistQuery(ctx, r.newSelect(ctx, options...).Columns("artist.id")).GroupBy("artist.id") - if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(ctx, sq, "artist.id") - } - ids := []string{} - err := r.queryAllSlice(ctx, sq, &ids) - return ids, err -} - -// hydrateArtwork fills each artist's ImageHash/ImageAbsent from one batched item_artwork lookup. -func (r *artistRepository) hydrateArtwork(ctx context.Context, artists model.Artists) { - hydrateItems(ctx, r.db, model.KindArtistArtwork, artists, - func(a *model.Artist) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) -} - -func (r *artistRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.ArtistCursor, error) { - ids, err := r.getAllIDs(ctx, options...) - if err != nil { - return nil, err - } - opts := chunkOptions(options, "artist.id") - return model.ArtistCursor(streamByIDs(ids, func(chunk []string) (model.Artists, error) { - return r.GetAll(ctx, opts(chunk)) - })), nil -} - func (r *artistRepository) getIndexKey(a model.Artist) string { source := a.OrderArtistName if conf.Server.PreferSortTags { @@ -324,7 +274,7 @@ func (r *artistRepository) getIndexKey(a model.Artist) string { // GetIndex returns a list of artists grouped by the first letter of their name, or by the index group if configured. // It can filter by roles and libraries, and optionally include artists that are missing (i.e., have no albums). // TODO Cache the index (recalculate at scan time) -func (r *artistRepository) GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { +func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { // Validate library IDs. If no library IDs are provided, return an empty index. if len(libraryIds) == 0 { return nil, nil @@ -352,7 +302,7 @@ func (r *artistRepository) GetIndex(ctx context.Context, includeMissing bool, li options.Filters = And{options.Filters, libFilter} } - artists, err := r.GetAll(ctx, options) + artists, err := r.GetAll(options) if err != nil { return nil, err } @@ -367,7 +317,7 @@ func (r *artistRepository) GetIndex(ctx context.Context, includeMissing bool, li return result, nil } -func (r *artistRepository) purgeEmpty(ctx context.Context) error { +func (r *artistRepository) purgeEmpty() error { orphanFilter := "id not in (select artist_id from album_artists)" // Collect uploaded image filenames before deleting @@ -375,18 +325,18 @@ func (r *artistRepository) purgeEmpty(ctx context.Context) error { Where(orphanFilter). Where("uploaded_image != ''") var imageFiles []string - if err := r.queryAllSlice(ctx, sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) { + if err := r.queryAllSlice(sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) { return fmt.Errorf("collecting artist images for cleanup: %w", err) } // Delete orphan artists del := Delete(r.tableName).Where(orphanFilter) - c, err := r.executeSQL(ctx, del) + c, err := r.executeSQL(del) if err != nil { return fmt.Errorf("purging empty artists: %w", err) } if c > 0 { - log.Debug(ctx, "Purged empty artists", "totalDeleted", c) + log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c) } if len(imageFiles) == 0 { @@ -394,31 +344,18 @@ func (r *artistRepository) purgeEmpty(ctx context.Context) error { } // Best-effort cleanup of uploaded image files - log.Debug(ctx, "Cleaning up artist images", "totalImages", len(imageFiles)) + log.Debug(r.ctx, "Cleaning up artist images", "totalImages", len(imageFiles)) for _, filename := range imageFiles { path := model.UploadedImagePath(consts.EntityArtist, filename) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Failed to remove artist image during GC", "path", path, err) + log.Warn(r.ctx, "Failed to remove artist image during GC", "path", path, err) } } 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(ctx context.Context) error { - _, err := r.executeSQL(ctx, 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(ctx context.Context) error { +func (r *artistRepository) markMissing() error { q := Expr(` with artists_with_non_missing_albums as ( select distinct aa.artist_id @@ -429,7 +366,7 @@ with artists_with_non_missing_albums as ( update artist set missing = (artist.id not in (select artist_id from artists_with_non_missing_albums)); `) - _, err := r.executeSQL(ctx, q) + _, err := r.executeSQL(q) if err != nil { return fmt.Errorf("marking missing artists: %w", err) } @@ -438,7 +375,7 @@ set missing = (artist.id not in (select artist_id from artists_with_non_missing_ // RefreshPlayCounts updates the play count and last play date annotations for all artists, based // on the media files associated with them. -func (r *artistRepository) RefreshPlayCounts(ctx context.Context) (int64, error) { +func (r *artistRepository) RefreshPlayCounts() (int64, error) { query := Expr(` with play_counts as ( select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date @@ -456,13 +393,13 @@ on conflict (user_id, item_id, item_type) do update set play_count = excluded.play_count, play_date = excluded.play_date; `) - return r.executeSQL(ctx, query) + return r.executeSQL(query) } // RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time. // When allArtists is true, it refreshes stats for all artists. It processes artists in batches to handle potentially large updates. // This method now calculates per-library statistics and stores them in the library_artist junction table. -func (r *artistRepository) RefreshStats(ctx context.Context, allArtists bool) (int64, error) { +func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { var allTouchedArtistIDs []string if allArtists { // Refresh stats for all artists @@ -470,7 +407,7 @@ func (r *artistRepository) RefreshStats(ctx context.Context, allArtists bool) (i if err := r.db.NewQuery(allArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil { return 0, fmt.Errorf("fetching all artist IDs: %w", err) } - log.Debug(ctx, "RefreshStats: Refreshing all artists.", "count", len(allTouchedArtistIDs)) + log.Debug(r.ctx, "RefreshStats: Refreshing all artists.", "count", len(allTouchedArtistIDs)) } else { // Only refresh artists with updated timestamps touchedArtistsQuerySQL := ` @@ -481,11 +418,11 @@ func (r *artistRepository) RefreshStats(ctx context.Context, allArtists bool) (i if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil { return 0, fmt.Errorf("fetching touched artist IDs: %w", err) } - log.Debug(ctx, "RefreshStats: Refreshing touched artists.", "count", len(allTouchedArtistIDs)) + log.Debug(r.ctx, "RefreshStats: Refreshing touched artists.", "count", len(allTouchedArtistIDs)) } if len(allTouchedArtistIDs) == 0 { - log.Debug(ctx, "RefreshStats: No artists to update.") + log.Debug(r.ctx, "RefreshStats: No artists to update.") return 0, nil } @@ -558,7 +495,7 @@ func (r *artistRepository) RefreshStats(ctx context.Context, allArtists bool) (i batchCounter := 0 for artistIDBatch := range slice.CollectChunks(slices.Values(allTouchedArtistIDs), batchSize) { batchCounter++ - log.Trace(ctx, "RefreshStats: Processing batch", "batchNum", batchCounter, "batchSize", len(artistIDBatch)) + log.Trace(r.ctx, "RefreshStats: Processing batch", "batchNum", batchCounter, "batchSize", len(artistIDBatch)) // Create placeholders for each ID in the IN clauses placeholders := make([]string, len(artistIDBatch)) @@ -584,170 +521,77 @@ func (r *artistRepository) RefreshStats(ctx context.Context, allArtists bool) (i // Now use Expr with the expanded SQL and all parameters sqlizer := Expr(batchSQL, args...) - rowsAffected, err := r.executeSQL(ctx, sqlizer) + rowsAffected, err := r.executeSQL(sqlizer) if err != nil { return totalRowsAffected, fmt.Errorf("executing batch update for artist stats (batch %d): %w", batchCounter, err) } totalRowsAffected += rowsAffected } - // Remove library_artist entries for artists that no longer have any content in a library. + // // Remove library_artist entries for artists that no longer have any content in any library cleanupSQL := Delete("library_artist").Where("stats = '{}'") - cleanupRows, err := r.executeSQL(ctx, cleanupSQL) + cleanupRows, err := r.executeSQL(cleanupSQL) if err != nil { - log.Warn(ctx, "Failed to cleanup empty library_artist entries", err) - } else { - if cleanupRows > 0 { - log.Debug(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(ctx); err != nil { - log.Warn(ctx, "Failed to mark orphaned artists missing after library_artist cleanup", err) - } - } + 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.Debug(ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected) + log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected) return totalRowsAffected, nil } -// 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 { +func (r *artistRepository) searchCfg() 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"}, - // 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)) - }, + NaturalOrder: "artist.id", + OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"}, + MBIDFields: []string{"mbz_artist_id"}, + LibraryFilter: r.applyLibraryFilterToArtistQuery, } } -// 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(ctx context.Context, q string, options ...model.QueryOptions) (model.Artists, error) { +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(ctx, opts.Filters) - if isLibraryIDFilter(opts.Filters) { - opts.Filters = nil - } var res dbArtists - err := r.doSearch(ctx, r.selectArtist(ctx, opts), q, &res, r.searchCfg(scope), opts) + err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts) if err != nil { return nil, fmt.Errorf("searching artist %q: %w", q, err) } - artists := res.toModels() - r.hydrateArtwork(ctx, artists) - return artists, nil + 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(ctx context.Context, filter Sqlizer) []int { - visible, err := r.visibleLibraryIDs(ctx) - 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(ctx, 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 +func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -// 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 +func (r *artistRepository) Read(id string) (any, error) { + return r.Get(id) } -// 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(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) -} - -func (r *artistRepository) Read(ctx context.Context, id string) (*model.Artist, error) { - return r.Get(ctx, id) -} - -// sortMappingsForRole copies the shared mappings so a role-specific sort never leaks into them. -// Anything sanitizeArtistStatsRole rejects falls back to the "total" aggregate. -func (r *artistRepository) sortMappingsForRole(options ...rest.QueryOptions) map[string]string { +func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, error) { role := "total" if len(options) > 0 { if v, ok := options[0].Filters["role"].(string); ok { - if safe, ok := sanitizeArtistStatsRole(v); ok { - role = safe - } + role = v } } - mappings := maps.Clone(r.sortMappings) - mappings["song_count"] = "sum(stats->>'" + role + "'->>'m')" - mappings["album_count"] = "sum(stats->>'" + role + "'->>'a')" - mappings["size"] = "sum(stats->>'" + role + "'->>'s')" - return mappings + r.sortMappings["song_count"] = "sum(stats->>'" + role + "'->>'m')" + r.sortMappings["album_count"] = "sum(stats->>'" + role + "'->>'a')" + r.sortMappings["size"] = "sum(stats->>'" + role + "'->>'s')" + return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *artistRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Artist, error) { - scoped := *r - scoped.sortMappings = r.sortMappingsForRole(options...) - return scoped.GetAll(ctx, scoped.parseRestOptions(ctx, options...)) +func (r *artistRepository) EntityName() string { + return "artist" +} + +func (r *artistRepository) NewInstance() any { + return &model.Artist{} } var _ model.ArtistRepository = (*artistRepository)(nil) -var _ rest.Repository[model.Artist] = (*artistRepository)(nil) +var _ model.ResourceRepository = (*artistRepository)(nil) diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 914c2f89a..e2904466c 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -3,10 +3,8 @@ package persistence import ( "context" "encoding/json" - "fmt" "os" "path/filepath" - "sync" "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -16,7 +14,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -50,11 +47,6 @@ func createUserWithLibraries(userID string, libraryIDs []int) model.User { } var _ = Describe("ArtistRepository", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) Context("Core Functionality", func() { Describe("GetIndexKey", func() { @@ -118,165 +110,6 @@ var _ = Describe("ArtistRepository", func() { }) }) - Describe("sanitizeArtistStatsRole", func() { - It("allowlists total and registered roles", func() { - role, ok := sanitizeArtistStatsRole("total") - Expect(ok).To(BeTrue()) - Expect(role).To(Equal("total")) - role, ok = sanitizeArtistStatsRole("albumartist") - Expect(ok).To(BeTrue()) - Expect(role).To(Equal("albumartist")) - }) - - It("rejects SQL injection payloads used in sort mappings", func() { - payload := "total'||(SELECT password FROM user LIMIT 1)||'" - role, ok := sanitizeArtistStatsRole(payload) - Expect(ok).To(BeFalse()) - Expect(role).To(BeEmpty()) - }) - }) - - Describe("ReadAll role sort", func() { - payload := "total') OR 1=1--" - - songCountSortFor := func(role any) string { - repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) - return repo.sortMappingsForRole(rest.QueryOptions{Filters: map[string]any{"role": role}})["song_count"] - } - - It("falls back to the total stats role for an attacker-controlled role", func() { - Expect(songCountSortFor(payload)).To(Equal("sum(stats->>'total'->>'m')")) - Expect(songCountSortFor("bogus")).To(Equal("sum(stats->>'total'->>'m')")) - Expect(songCountSortFor(42)).To(Equal("sum(stats->>'total'->>'m')")) - }) - - It("keeps valid role sort paths", func() { - Expect(songCountSortFor("composer")).To(Equal("sum(stats->>'composer'->>'m')")) - Expect(songCountSortFor("albumartist")).To(Equal("sum(stats->>'albumartist'->>'m')")) - }) - - It("leaves the shared mappings untouched", func() { - repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) - Expect(repo.sortMappingsForRole(rest.QueryOptions{Filters: map[string]any{"role": "composer"}})). - ToNot(Equal(repo.sortMappings)) - Expect(repo.sortMappings["song_count"]).To(Equal("stats->>'total'->>'m'")) - }) - - It("orders by the requested role's stats, not the total", func() { - ctx := request.WithUser(GinkgoT().Context(), adminUser) - repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) - // Composer and total counts rank the two artists in opposite orders, so the - // resulting order alone proves which mapping the sort used. - seed := func(artistID, stats string) { - _, err := repo.executeSQL(ctx, squirrel.Insert("library_artist"). - Columns("library_id", "artist_id", "stats"). - Values(1, artistID, stats). - Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats")) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - _, _ = repo.executeSQL(ctx, squirrel.Update("library_artist").Set("stats", "{}"). - Where(squirrel.Eq{"library_id": 1, "artist_id": artistID})) - }) - } - seed(artistBeatles.ID, `{"composer": {"s": 1, "m": 1, "a": 1}, "total": {"s": 1, "m": 100, "a": 1}}`) - seed(artistKraftwerk.ID, `{"composer": {"s": 1, "m": 9, "a": 1}, "total": {"s": 1, "m": 2, "a": 1}}`) - - res, err := repo.ReadAll(ctx, rest.QueryOptions{ - Sort: "songCount", - Order: "DESC", - Filters: map[string]any{"role": "composer"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(res, func(a model.Artist) string { return a.ID })). - To(Equal([]string{artistKraftwerk.ID, artistBeatles.ID})) - }) - - It("still returns results when the role is an injection payload", func() { - ctx := request.WithUser(GinkgoT().Context(), adminUser) - repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) - _, err := repo.ReadAll(ctx, rest.QueryOptions{ - Sort: "songCount", - Order: "ASC", - Filters: map[string]any{"role": payload}, - }) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - 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(GetDBXBuilder()).(*artistRepository) - return r.searchScope(ctx, 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(GetDBXBuilder()).CountAll(ctx) - 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(GetDBXBuilder()).(*libraryRepository). - queryAllSlice(ctx, 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 @@ -355,95 +188,30 @@ var _ = Describe("ArtistRepository", func() { var repo model.ArtistRepository BeforeEach(func() { + ctx := GinkgoT().Context() ctx = request.WithUser(ctx, adminUser) - repo = NewArtistRepository(GetDBXBuilder()) - }) - - Describe("ReadAll with role sort", func() { - It("does not change the shared sort mappings", func() { - original := repo.(*artistRepository).sortMappings["song_count"] - var wg sync.WaitGroup - for i := 0; i < 20; i++ { - role := "artist" - if i%2 == 1 { - role = "composer" - } - wg.Add(1) - go func() { - defer GinkgoRecover() - defer wg.Done() - _, err := repo.ReadAll(ctx, rest.QueryOptions{Sort: "song_count", Filters: map[string]any{"role": role}}) - Expect(err).ToNot(HaveOccurred()) - }() - } - wg.Wait() - Expect(repo.(*artistRepository).sortMappings["song_count"]).To(Equal(original)) - }) - }) - - Describe("GetCursor", func() { - It("yields the same artists as GetAll", func() { - opts := model.QueryOptions{Sort: "name"} - want, err := repo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.Artist(want))) - }) - - It("honors Max/Offset like GetAll", func() { - opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} - want, err := repo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.Artist(want))) - }) - }) - - Describe("getAllIDs", func() { - It("returns the same id set as GetAll", func() { - want, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - ids, err := repo.(*artistRepository).getAllIDs(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) - }) + repo = NewArtistRepository(ctx, GetDBXBuilder()) }) Describe("Basic Operations", func() { Describe("Count", func() { It("returns the number of artists in the DB", func() { - Expect(repo.CountAll(ctx)).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(ctx, 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(ctx, model.QueryOptions{ - Filters: annotationBoolFilter("rating")("rating", "false"), - }) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(4))) + Expect(repo.CountAll()).To(Equal(int64(4))) }) }) Describe("Exists", func() { It("returns true for an artist that is in the DB", func() { - Expect(repo.Exists(ctx, "3")).To(BeTrue()) + Expect(repo.Exists("3")).To(BeTrue()) }) It("returns false for an artist that is NOT in the DB", func() { - Expect(repo.Exists(ctx, "666")).To(BeFalse()) + Expect(repo.Exists("666")).To(BeFalse()) }) }) Describe("Get", func() { It("retrieves existing artist data", func() { - artist, err := repo.Get(ctx, "2") + artist, err := repo.Get("2") Expect(err).ToNot(HaveOccurred()) Expect(artist.Name).To(Equal(artistKraftwerk.Name)) }) @@ -458,10 +226,10 @@ var _ = Describe("ArtistRepository", func() { It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() { // Set SortArtistName to "Foo" for Beatles artistBeatles.SortArtistName = "Foo" - er := repo.Put(ctx, &artistBeatles) + er := repo.Put(&artistBeatles) Expect(er).To(BeNil()) - idx, err := repo.GetIndex(ctx, false, []int{1}) + idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("F")) @@ -479,13 +247,13 @@ var _ = Describe("ArtistRepository", func() { // Restore the original value artistBeatles.SortArtistName = "" - er = repo.Put(ctx, &artistBeatles) + er = repo.Put(&artistBeatles) Expect(er).To(BeNil()) }) // BFR Empty SortArtistName is not saved in the DB anymore XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() { - idx, err := repo.GetIndex(ctx, false, []int{1}) + idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("B")) @@ -510,10 +278,10 @@ var _ = Describe("ArtistRepository", func() { It("returns the index when SortArtistName is NOT empty", func() { // Set SortArtistName to "Foo" for Beatles artistBeatles.SortArtistName = "Foo" - er := repo.Put(ctx, &artistBeatles) + er := repo.Put(&artistBeatles) Expect(er).To(BeNil()) - idx, err := repo.GetIndex(ctx, false, []int{1}) + idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("B")) @@ -531,12 +299,12 @@ var _ = Describe("ArtistRepository", func() { // Restore the original value artistBeatles.SortArtistName = "" - er = repo.Put(ctx, &artistBeatles) + er = repo.Put(&artistBeatles) Expect(er).To(BeNil()) }) It("returns the index when SortArtistName is empty", func() { - idx, err := repo.GetIndex(ctx, false, []int{1}) + idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) Expect(idx[0].ID).To(Equal("B")) @@ -564,14 +332,14 @@ var _ = Describe("ArtistRepository", func() { producerStats := `{"producer": {"s": 500, "m": 3, "a": 1}}` // Set Beatles as composer in library 1 - _, err := raw.executeSQL(ctx, squirrel.Insert("library_artist"). + _, err := raw.executeSQL(squirrel.Insert("library_artist"). Columns("library_id", "artist_id", "stats"). Values(1, artistBeatles.ID, composerStats). Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats")) Expect(err).ToNot(HaveOccurred()) // Set Kraftwerk as producer in library 1 - _, err = raw.executeSQL(ctx, squirrel.Insert("library_artist"). + _, err = raw.executeSQL(squirrel.Insert("library_artist"). Columns("library_id", "artist_id", "stats"). Values(1, artistKraftwerk.ID, producerStats). Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats")) @@ -580,16 +348,16 @@ var _ = Describe("ArtistRepository", func() { AfterEach(func() { // Clean up stats from library_artist table - _, _ = raw.executeSQL(ctx, squirrel.Update("library_artist"). + _, _ = raw.executeSQL(squirrel.Update("library_artist"). Set("stats", "{}"). Where(squirrel.Eq{"artist_id": artistBeatles.ID, "library_id": 1})) - _, _ = raw.executeSQL(ctx, squirrel.Update("library_artist"). + _, _ = raw.executeSQL(squirrel.Update("library_artist"). Set("stats", "{}"). Where(squirrel.Eq{"artist_id": artistKraftwerk.ID, "library_id": 1})) }) It("returns only artists with the specified role", func() { - idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleComposer) + idx, err := repo.GetIndex(false, []int{1}, model.RoleComposer) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(1)) Expect(idx[0].ID).To(Equal("B")) @@ -598,7 +366,7 @@ var _ = Describe("ArtistRepository", func() { }) It("returns artists with any of the specified roles", func() { - idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleComposer, model.RoleProducer) + idx, err := repo.GetIndex(false, []int{1}, model.RoleComposer, model.RoleProducer) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(2)) @@ -619,7 +387,7 @@ var _ = Describe("ArtistRepository", func() { }) It("returns empty index when no artists have the specified role", func() { - idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleDirector) + idx, err := repo.GetIndex(false, []int{1}, model.RoleDirector) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(0)) }) @@ -627,19 +395,19 @@ var _ = Describe("ArtistRepository", func() { When("validating library IDs", func() { It("returns nil when no library IDs are provided", func() { - idx, err := repo.GetIndex(ctx, false, []int{}) + idx, err := repo.GetIndex(false, []int{}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(BeNil()) }) It("returns artists when library IDs are provided (admin user sees all content)", func() { // Admin users can see all content when valid library IDs are provided - idx, err := repo.GetIndex(ctx, false, []int{1}) + idx, err := repo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) // With non-existent library ID, admin users see no content because no artists are associated with that library - idx, err = repo.GetIndex(ctx, false, []int{999}) + idx, err = repo.GetIndex(false, []int{999}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(0)) // Even admin users need valid library associations }) @@ -652,23 +420,23 @@ var _ = Describe("ArtistRepository", func() { BeforeEach(func() { // Create artist without any annotation artistWithoutAnnotation = model.Artist{ID: "no-annotation-artist", Name: "No Annotation Artist"} - err := createArtistWithLibrary(ctx, repo, &artistWithoutAnnotation, 1) + err := createArtistWithLibrary(repo, &artistWithoutAnnotation, 1) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID})) + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID})) } }) Describe("starred", func() { It("false includes items without annotations", func() { - res, err := repo.ReadAll(ctx, rest.QueryOptions{ + res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ Filters: map[string]any{"starred": "false"}, }) Expect(err).ToNot(HaveOccurred()) - artists := res + artists := res.(model.Artists) var found bool for _, a := range artists { @@ -681,11 +449,11 @@ var _ = Describe("ArtistRepository", func() { }) It("true excludes items without annotations", func() { - res, err := repo.ReadAll(ctx, rest.QueryOptions{ + res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ Filters: map[string]any{"starred": "true"}, }) Expect(err).ToNot(HaveOccurred()) - artists := res + artists := res.(model.Artists) for _, a := range artists { Expect(a.ID).ToNot(Equal(artistWithoutAnnotation.ID)) @@ -697,63 +465,57 @@ var _ = Describe("ArtistRepository", func() { Describe("MBID and Text Search", func() { var lib2 model.Library var lr model.LibraryRepository - var lrCtx context.Context var restrictedUser model.User var restrictedRepo model.ArtistRepository - var restrictedCtx context.Context var headlessRepo model.ArtistRepository - var headlessCtx context.Context BeforeEach(func() { // Set up headless repo (no user context) - headlessCtx = GinkgoT().Context() - headlessRepo = NewArtistRepository(GetDBXBuilder()) + headlessRepo = NewArtistRepository(context.Background(), GetDBXBuilder()) // Create library for testing access restrictions lib2 = model.Library{ID: 0, Name: "Artist Test Library", Path: "/artist/test/lib"} - lrCtx = request.WithUser(ctx, adminUser) - lr = NewLibraryRepository(GetDBXBuilder()) - err := lr.Put(lrCtx, &lib2) + lr = NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + err := lr.Put(&lib2) Expect(err).ToNot(HaveOccurred()) // Create a user with access to only library 1 restrictedUser = createUserWithLibraries("search_user", []int{1}) // Create repository context for the restricted user - restrictedCtx = request.WithUser(ctx, restrictedUser) - restrictedRepo = NewArtistRepository(GetDBXBuilder()) + ctx := request.WithUser(GinkgoT().Context(), restrictedUser) + restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) // Ensure both test artists are associated with library 1 - err = lr.AddArtist(lrCtx, 1, artistBeatles.ID) + err = lr.AddArtist(1, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) - err = lr.AddArtist(lrCtx, 1, artistKraftwerk.ID) + err = lr.AddArtist(1, artistKraftwerk.ID) Expect(err).ToNot(HaveOccurred()) // Create the restricted user in the database - urCtx := request.WithUser(ctx, adminUser) - ur := NewUserRepository(GetDBXBuilder()) - err = ur.Put(urCtx, &restrictedUser) + ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + err = ur.Put(&restrictedUser) Expect(err).ToNot(HaveOccurred()) - err = ur.SetUserLibraries(urCtx, restrictedUser.ID, []int{1}) + err = ur.SetUserLibraries(restrictedUser.ID, []int{1}) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { // Clean up library 2 - lr := NewLibraryRepository(GetDBXBuilder()) - _ = lr.(*libraryRepository).delete(ctx, squirrel.Eq{"id": lib2.ID}) + lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + _ = lr.(*libraryRepository).delete(squirrel.Eq{"id": lib2.ID}) }) DescribeTable("MBID search behavior across different user types", - func(testRepo *model.ArtistRepository, testCtx *context.Context, shouldFind bool, testDesc string) { + func(testRepo *model.ArtistRepository, shouldFind bool, testDesc string) { // Create test artist with MBID artistWithMBID := createTestArtistWithMBID("test-mbid-artist", "Test MBID Artist", "550e8400-e29b-41d4-a716-446655440010") - err := createArtistWithLibrary(*testCtx, *testRepo, &artistWithMBID, 1) + err := createArtistWithLibrary(*testRepo, &artistWithMBID, 1) Expect(err).ToNot(HaveOccurred()) // Test the search - results, err := (*testRepo).Search(*testCtx, "550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10}) + results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) if shouldFind { @@ -765,43 +527,43 @@ var _ = Describe("ArtistRepository", func() { // Clean up if raw, ok := (*testRepo).(*artistRepository); ok { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) } }, - Entry("Admin user can find artist by MBID", &repo, &ctx, true, "Admin should find MBID artist"), - Entry("Restricted user can find artist by MBID in accessible library", &restrictedRepo, &restrictedCtx, true, "Restricted user should find MBID artist in accessible library"), - Entry("Headless process can find artist by MBID", &headlessRepo, &headlessCtx, true, "Headless process should find MBID artist"), + Entry("Admin user can find artist by MBID", &repo, true, "Admin should find MBID artist"), + Entry("Restricted user can find artist by MBID in accessible library", &restrictedRepo, true, "Restricted user should find MBID artist in accessible library"), + Entry("Headless process can find artist by MBID", &headlessRepo, true, "Headless process should find MBID artist"), ) It("prevents restricted user from finding artist by MBID when not in accessible library", func() { // Create an artist in library 2 (not accessible to restricted user) inaccessibleArtist := createTestArtistWithMBID("inaccessible-mbid-artist", "Inaccessible MBID Artist", "a74b1b7f-71a5-4011-9441-d0b5e4122711") - err := repo.Put(ctx, &inaccessibleArtist) + err := repo.Put(&inaccessibleArtist) Expect(err).ToNot(HaveOccurred()) // Add to library 2 (not accessible to restricted user) - err = lr.AddArtist(lrCtx, lib2.ID, inaccessibleArtist.ID) + err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) Expect(err).ToNot(HaveOccurred()) // Restricted user should not find this artist - results, err := restrictedRepo.Search(restrictedCtx, "a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) + results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) // But admin should find it - results, err = repo.Search(ctx, "a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) + results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) // Clean up if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) } }) Context("Text Search", func() { It("allows admin to find artists by name regardless of library", func() { - results, err := repo.Search(ctx, "Beatles", model.QueryOptions{Max: 10}) + results, err := repo.Search("Beatles", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("The Beatles")) @@ -813,113 +575,21 @@ var _ = Describe("ArtistRepository", func() { ID: "inaccessible-text-artist", Name: "Unique Search Name Artist", } - err := repo.Put(ctx, &inaccessibleArtist) + err := repo.Put(&inaccessibleArtist) Expect(err).ToNot(HaveOccurred()) // Add to library 2 (not accessible to restricted user) - err = lr.AddArtist(lrCtx, lib2.ID, inaccessibleArtist.ID) + err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) Expect(err).ToNot(HaveOccurred()) // Restricted user should not find this artist - results, err := restrictedRepo.Search(restrictedCtx, "Unique Search Name", model.QueryOptions{Max: 10}) + results, err := restrictedRepo.Search("Unique Search Name", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty(), "Text search should respect library filtering") // Clean up if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) - } - }) - }) - - 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(lrCtx, lib2.ID, artistBeatles.ID)).To(Succeed()) - - all, err := repo.Search(ctx, "", 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(ctx, "", 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(ctx, "", 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(ctx, "", 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(ctx, &lib2Artist)).To(Succeed()) - Expect(lr.AddArtist(lrCtx, lib2.ID, lib2Artist.ID)).To(Succeed()) - - results, err := restrictedRepo.Search(restrictedCtx, "", 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(ctx, 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(ctx, &lib2Artist)).To(Succeed()) - Expect(lr.AddArtist(lrCtx, lib2.ID, lib2Artist.ID)).To(Succeed()) - DeferCleanup(func() { - if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) - } - }) - - all, err := restrictedRepo.Search(restrictedCtx, "", 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(restrictedCtx, "", 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)) + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) } }) }) @@ -927,11 +597,11 @@ var _ = Describe("ArtistRepository", func() { 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 - err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID) + err := lr.AddArtist(lib2.ID, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) // Headless processes should see all artists regardless of library - artists, err := headlessRepo.GetAll(headlessCtx) + artists, err := headlessRepo.GetAll() Expect(err).ToNot(HaveOccurred()) // Should see all artists from all libraries @@ -947,11 +617,11 @@ var _ = Describe("ArtistRepository", func() { It("should allow headless processes to apply explicit library_id filters", func() { // Add artists to different libraries - err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID) + err := lr.AddArtist(lib2.ID, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) // Filter by specific library - artists, err := headlessRepo.GetAll(headlessCtx, model.QueryOptions{ + artists, err := headlessRepo.GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -967,11 +637,11 @@ var _ = Describe("ArtistRepository", func() { It("should get individual artists when no user is in context", func() { // Add artist to a library - err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID) + err := lr.AddArtist(lib2.ID, artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) // Headless process should be able to get the artist - artist, err := headlessRepo.Get(headlessCtx, artistBeatles.ID) + artist, err := headlessRepo.Get(artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) Expect(artist.ID).To(Equal(artistBeatles.ID)) }) @@ -980,15 +650,15 @@ var _ = Describe("ArtistRepository", func() { Describe("Admin User Library Access", func() { It("sees all artists regardless of library permissions", func() { - count, err := repo.CountAll(ctx) + count, err := repo.CountAll() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(4))) - artists, err := repo.GetAll(ctx) + artists, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(artists).To(HaveLen(4)) - exists, err := repo.Exists(ctx, artistBeatles.ID) + exists, err := repo.Exists(artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeTrue()) }) @@ -1003,25 +673,25 @@ var _ = Describe("ArtistRepository", func() { missingArtist = model.Artist{ID: "missing_test", Name: "Missing Artist", OrderArtistName: "missing artist"} // Create and mark as missing - err := createArtistWithLibrary(ctx, repo, &missingArtist, 1) + err := createArtistWithLibrary(repo, &missingArtist, 1) Expect(err).ToNot(HaveOccurred()) - _, err = raw.executeSQL(ctx, squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID})) + _, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID})) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) }) It("missing artists are never returned by search", func() { // Should see missing artist in GetAll by default for admin users - artists, err := repo.GetAll(ctx) + artists, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(artists).To(HaveLen(5)) // Including the missing artist // Search never returns missing artists (hardcoded behavior) - results, err := repo.Search(ctx, "Missing Artist", model.QueryOptions{Max: 10}) + results, err := repo.Search("Missing Artist", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -1030,7 +700,6 @@ var _ = Describe("ArtistRepository", func() { Context("Regular User Operations", func() { var restrictedRepo model.ArtistRepository - var restrictedCtx context.Context var unauthorizedUser model.User BeforeEach(func() { @@ -1038,54 +707,55 @@ var _ = Describe("ArtistRepository", func() { unauthorizedUser = model.User{ID: "restricted_user", UserName: "restricted", Name: "Restricted User", Email: "restricted@test.com", IsAdmin: false} // Create repository context for the unauthorized user - restrictedCtx = request.WithUser(ctx, unauthorizedUser) - restrictedRepo = NewArtistRepository(GetDBXBuilder()) + ctx := GinkgoT().Context() + ctx = request.WithUser(ctx, unauthorizedUser) + restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) }) Describe("Library Access Restrictions", func() { It("CountAll returns 0 for users without library access", func() { - count, err := restrictedRepo.CountAll(restrictedCtx) + count, err := restrictedRepo.CountAll() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(0))) }) It("GetAll returns empty list for users without library access", func() { - artists, err := restrictedRepo.GetAll(restrictedCtx) + artists, err := restrictedRepo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(artists).To(BeEmpty()) }) It("Exists returns false for existing artists when user has no library access", func() { // These artists exist in the DB but the user has no access to them - exists, err := restrictedRepo.Exists(restrictedCtx, artistBeatles.ID) + exists, err := restrictedRepo.Exists(artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeFalse()) - exists, err = restrictedRepo.Exists(restrictedCtx, artistKraftwerk.ID) + exists, err = restrictedRepo.Exists(artistKraftwerk.ID) Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeFalse()) }) It("Get returns ErrNotFound for existing artists when user has no library access", func() { - _, err := restrictedRepo.Get(restrictedCtx, artistBeatles.ID) + _, err := restrictedRepo.Get(artistBeatles.ID) Expect(err).To(Equal(model.ErrNotFound)) - _, err = restrictedRepo.Get(restrictedCtx, artistKraftwerk.ID) + _, err = restrictedRepo.Get(artistKraftwerk.ID) Expect(err).To(Equal(model.ErrNotFound)) }) It("Search returns empty results for users without library access", func() { - results, err := restrictedRepo.Search(restrictedCtx, "Beatles", model.QueryOptions{Max: 10}) + results, err := restrictedRepo.Search("Beatles", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) - results, err = restrictedRepo.Search(restrictedCtx, "Kraftwerk", model.QueryOptions{Max: 10}) + results, err = restrictedRepo.Search("Kraftwerk", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("GetIndex returns empty index for users without library access", func() { - idx, err := restrictedRepo.GetIndex(restrictedCtx, false, []int{1}) + idx, err := restrictedRepo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(0)) }) @@ -1093,43 +763,42 @@ var _ = Describe("ArtistRepository", func() { Context("when user gains library access", func() { BeforeEach(func() { + ctx := GinkgoT().Context() // Give the user access to library 1 - urCtx := request.WithUser(ctx, adminUser) - ur := NewUserRepository(GetDBXBuilder()) + ur := NewUserRepository(request.WithUser(ctx, adminUser), GetDBXBuilder()) // First create the user if not exists - err := ur.Put(urCtx, &unauthorizedUser) + err := ur.Put(&unauthorizedUser) Expect(err).ToNot(HaveOccurred()) // Then add library access - err = ur.SetUserLibraries(urCtx, unauthorizedUser.ID, []int{1}) + err = ur.SetUserLibraries(unauthorizedUser.ID, []int{1}) Expect(err).ToNot(HaveOccurred()) // Update the user object with the libraries to simulate middleware behavior - libraries, err := ur.GetUserLibraries(urCtx, unauthorizedUser.ID) + libraries, err := ur.GetUserLibraries(unauthorizedUser.ID) Expect(err).ToNot(HaveOccurred()) unauthorizedUser.Libraries = libraries // Recreate repository context with updated user - restrictedCtx = request.WithUser(ctx, unauthorizedUser) - restrictedRepo = NewArtistRepository(GetDBXBuilder()) + ctx = request.WithUser(ctx, unauthorizedUser) + restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder()) }) AfterEach(func() { // Clean up: remove the user's library access - urCtx := request.WithUser(ctx, adminUser) - ur := NewUserRepository(GetDBXBuilder()) - _ = ur.SetUserLibraries(urCtx, unauthorizedUser.ID, []int{}) + ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + _ = ur.SetUserLibraries(unauthorizedUser.ID, []int{}) }) It("CountAll returns correct count after gaining access", func() { - count, err := restrictedRepo.CountAll(restrictedCtx) + count, err := restrictedRepo.CountAll() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(4))) // Beatles, Kraftwerk, Seatbelts, and The Roots }) It("GetAll returns artists after gaining access", func() { - artists, err := restrictedRepo.GetAll(restrictedCtx) + artists, err := restrictedRepo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(artists).To(HaveLen(4)) @@ -1141,66 +810,26 @@ var _ = Describe("ArtistRepository", func() { }) It("Exists returns true for accessible artists", func() { - exists, err := restrictedRepo.Exists(restrictedCtx, artistBeatles.ID) + exists, err := restrictedRepo.Exists(artistBeatles.ID) Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeTrue()) - exists, err = restrictedRepo.Exists(restrictedCtx, artistKraftwerk.ID) + exists, err = restrictedRepo.Exists(artistKraftwerk.ID) Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeTrue()) }) It("GetIndex returns artists with proper library filtering", func() { // With valid library access, should see artists - idx, err := restrictedRepo.GetIndex(restrictedCtx, false, []int{1}) + idx, err := restrictedRepo.GetIndex(false, []int{1}) Expect(err).ToNot(HaveOccurred()) Expect(idx).To(HaveLen(4)) // With non-existent library ID, should see nothing (non-admin user) - idx, err = restrictedRepo.GetIndex(restrictedCtx, false, []int{999}) + idx, err = restrictedRepo.GetIndex(false, []int{999}) 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. - adminCtx := request.WithUser(ctx, adminUser) - adminRepo := NewArtistRepository(GetDBXBuilder()) - adminAll, err := adminRepo.Search(adminCtx, "", model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - - userAll, err := restrictedRepo.Search(restrictedCtx, "", 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(GetDBXBuilder()).CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(total).To(BeNumerically(">", 0)) - - allLibs := make([]int, total) - for i := range allLibs { - allLibs[i] = i + 1 - } - Expect(raw.userSeesAllLibraries(restrictedCtx, allLibs)).To(BeTrue()) - Expect(raw.userSeesAllLibraries(restrictedCtx, allLibs[:total-1])).To(BeFalse()) - Expect(raw.userSeesAllLibraries(restrictedCtx, []int{})).To(BeFalse()) - }) }) }) @@ -1211,10 +840,10 @@ var _ = Describe("ArtistRepository", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) + conf.Server.DataFolder = tmpDir - ctx = request.WithUser(ctx, adminUser) - repo = NewArtistRepository(GetDBXBuilder()).(*artistRepository) + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) }) // Helper to create an artist image file on disk and return its path @@ -1229,13 +858,13 @@ var _ = Describe("ArtistRepository", func() { It("removes uploaded image files for purged artists", func() { // Create an orphan artist (not in album_artists) with an uploaded image orphanArtist := model.Artist{ID: "orphan-with-image", Name: "Orphan Artist", UploadedImage: "orphan-with-image_Orphan_Artist.jpg"} - Expect(repo.Put(ctx, &orphanArtist)).To(Succeed()) + Expect(repo.Put(&orphanArtist)).To(Succeed()) imgPath := createImageFile("orphan-with-image_Orphan_Artist.jpg") - Expect(repo.purgeEmpty(ctx)).To(Succeed()) + Expect(repo.purgeEmpty()).To(Succeed()) // Artist should be gone from DB - exists, err := repo.Exists(ctx, "orphan-with-image") + exists, err := repo.Exists("orphan-with-image") Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeFalse()) @@ -1247,12 +876,12 @@ var _ = Describe("ArtistRepository", func() { It("handles missing image files gracefully", func() { // Artist has UploadedImage set but no actual file on disk orphanArtist := model.Artist{ID: "orphan-no-file", Name: "Ghost Image", UploadedImage: "orphan-no-file_Ghost_Image.jpg"} - Expect(repo.Put(ctx, &orphanArtist)).To(Succeed()) + Expect(repo.Put(&orphanArtist)).To(Succeed()) - Expect(repo.purgeEmpty(ctx)).To(Succeed()) + Expect(repo.purgeEmpty()).To(Succeed()) // Artist should be gone from DB - exists, err := repo.Exists(ctx, "orphan-no-file") + exists, err := repo.Exists("orphan-no-file") Expect(err).ToNot(HaveOccurred()) Expect(exists).To(BeFalse()) }) @@ -1260,24 +889,24 @@ var _ = Describe("ArtistRepository", func() { It("does not delete images for artists that are kept", func() { // Create an artist with an uploaded image AND an album_artists entry so it won't be purged keptArtist := model.Artist{ID: "kept-artist", Name: "Kept Artist", UploadedImage: "kept-artist_Kept_Artist.jpg"} - Expect(repo.Put(ctx, &keptArtist)).To(Succeed()) + Expect(repo.Put(&keptArtist)).To(Succeed()) imgPath := createImageFile("kept-artist_Kept_Artist.jpg") // Insert an album_artists record to keep this artist from being purged - _, err := repo.executeSQL(ctx, squirrel.Insert("album_artists"). + _, err := repo.executeSQL(squirrel.Insert("album_artists"). SetMap(map[string]any{"album_id": "101", "artist_id": "kept-artist", "role": "artist", "sub_role": ""})) Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { - _, _ = repo.executeSQL(ctx, squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"})) - _ = repo.delete(ctx, squirrel.Eq{"id": "kept-artist"}) + _, _ = repo.executeSQL(squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"})) + _ = repo.delete(squirrel.Eq{"id": "kept-artist"}) }) - Expect(repo.purgeEmpty(ctx)).To(Succeed()) + Expect(repo.purgeEmpty()).To(Succeed()) // Artist should still exist (check directly, bypassing library filter) var ids []string - err = repo.queryAllSlice(ctx, squirrel.Select("id").From("artist").Where(squirrel.Eq{"id": "kept-artist"}), &ids) + err = repo.queryAllSlice(squirrel.Select("id").From("artist").Where(squirrel.Eq{"id": "kept-artist"}), &ids) Expect(err).ToNot(HaveOccurred()) Expect(ids).To(HaveLen(1)) @@ -1286,77 +915,17 @@ 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(ctx, 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(ctx, adminUser) - repo = NewArtistRepository(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(ctx, &emptyArtist)).To(Succeed()) - _, err := repo.executeSQL(ctx, squirrel.Insert("library_artist"). - SetMap(map[string]any{"library_id": 1, "artist_id": emptyArtist.ID, "stats": "{}"})) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - _, _ = repo.executeSQL(ctx, squirrel.Delete("library_artist").Where(squirrel.Eq{"artist_id": emptyArtist.ID})) - _ = repo.delete(ctx, squirrel.Eq{"id": emptyArtist.ID}) - }) - - Expect(missing(emptyArtist.ID)).To(BeFalse()) - - _, err = repo.RefreshStats(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - Expect(missing(emptyArtist.ID)).To(BeTrue()) - var orphanIDs []string - Expect(repo.queryAllSlice(ctx, 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(ctx, &legacyOrphan)).To(Succeed()) - DeferCleanup(func() { - _ = repo.delete(ctx, squirrel.Eq{"id": legacyOrphan.ID}) - }) - - Expect(missing(legacyOrphan.ID)).To(BeFalse()) - - _, err := repo.RefreshStats(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - Expect(missing(legacyOrphan.ID)).To(BeTrue()) - }) - }) }) // Helper function to create an artist with proper library association. // This ensures test artists always have library_artist associations to avoid orphaned artists in tests. -func createArtistWithLibrary(ctx context.Context, repo model.ArtistRepository, artist *model.Artist, libraryID int) error { - err := repo.Put(ctx, artist) +func createArtistWithLibrary(repo model.ArtistRepository, artist *model.Artist, libraryID int) error { + err := repo.Put(artist) if err != nil { return err } // Add the artist to the specified library - lr := NewLibraryRepository(GetDBXBuilder()) - return lr.AddArtist(request.WithUser(ctx, adminUser), libraryID, artist.ID) + lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + return lr.AddArtist(libraryID, artist.ID) } diff --git a/persistence/artwork_hydration.go b/persistence/artwork_hydration.go deleted file mode 100644 index 76646ec74..000000000 --- a/persistence/artwork_hydration.go +++ /dev/null @@ -1,181 +0,0 @@ -package persistence - -import ( - "context" - "iter" - "slices" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" -) - -// Keeps the id IN-list under SQLite's bound-parameter limit; a whole multiple of artworkBatchSize -// so a page re-chunks into even hydration batches. -const artworkChunkSize = artworkBatchSize * 3 - -// streamByIDs yields rows in id chunks through the caller's hydrating fetch. Resolving ids first -// keeps OFFSET out of the joined query. -func streamByIDs[S ~[]T, T any](ids []string, fetch func(chunk []string) (S, error)) iter.Seq2[T, error] { - return func(yield func(T, error) bool) { - for chunk := range slices.Chunk(ids, artworkChunkSize) { - rows, err := fetch(chunk) - if err != nil { - var zero T - yield(zero, err) - return - } - for _, row := range rows { - if !yield(row, nil) { - return - } - } - } - } -} - -// chunkOptions narrows options to a chunk of ids, dropping Max/Offset (the id pre-pass already -// consumed them) and reusing Sort, which may be a seeded random expression by now. -func chunkOptions(options []model.QueryOptions, idField string) func([]string) model.QueryOptions { - var base model.QueryOptions - if len(options) > 0 { - base = model.QueryOptions{Sort: options[0].Sort, Order: options[0].Order, Filters: options[0].Filters} - } - return func(chunk []string) model.QueryOptions { - opts := base - opts.Filters = Eq{idField: chunk} - if base.Filters != nil { - opts.Filters = And{base.Filters, opts.Filters} - } - return opts - } -} - -// hydrateItemImages returns per-item artwork info in one batched query per kind. On error it logs -// and returns an empty map, so the page still renders. -func hydrateItemImages(ctx context.Context, db dbx.Builder, kind model.Kind, ids []string) map[string]model.ItemArtworkInfo { - if len(ids) == 0 { - return map[string]model.ItemArtworkInfo{} - } - infos, err := NewArtworkRepository(db).GetInfoForItems(ctx, kind, ids) - if err != nil { - log.Error(ctx, "Failed to hydrate artwork info onto page", "kind", kind, err) - return map[string]model.ItemArtworkInfo{} - } - return infos -} - -// applyItemImage copies a hydration entry onto img; a missing entry leaves it zero (unresolved). -func applyItemImage(infos map[string]model.ItemArtworkInfo, id string, img *model.ItemImage) { - if info, ok := infos[id]; ok { - *img = info.Image() - } -} - -// hydrateItems fills every item's embedded ItemImage from one batched lookup. ref yields an -// item's id and the ItemImage to fill, which is all that differs between the entity kinds. -func hydrateItems[T any](ctx context.Context, db dbx.Builder, kind model.Kind, items []T, - ref func(*T) (string, *model.ItemImage)) { - ids := make([]string, len(items)) - for i := range items { - ids[i], _ = ref(&items[i]) - } - infos := hydrateItemImages(ctx, db, kind, ids) - for i := range items { - id, img := ref(&items[i]) - applyItemImage(infos, id, img) - } -} - -// hydrateMediaFileArtwork mirrors MediaFile.CoverArtID: an embedded-eligible file with resolved own -// art uses it, else it falls back to the album's. -func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.MediaFiles) { - if len(mfs) == 0 { - return - } - albumIDs := make([]string, len(mfs)) - var eligibleIDs []string - for i := range mfs { - albumIDs[i] = mfs[i].AlbumID - if mfs[i].HasCoverArt && conf.Server.EnableMediaFileCoverArt { - eligibleIDs = append(eligibleIDs, mfs[i].ID) - } - } - albumInfos := hydrateItemImages(ctx, db, model.KindAlbumArtwork, albumIDs) - mfInfos := hydrateItemImages(ctx, db, model.KindMediaFileArtwork, eligibleIDs) - for i := range mfs { - mf := &mfs[i] - applyItemImage(albumInfos, mf.AlbumID, &mf.AlbumImage) - eligible := mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt - ownInfo, ownResolved := mfInfos[mf.ID] - if eligible && ownResolved && !ownInfo.Absent() { - mf.ItemImage = ownInfo.Image() - continue - } - ownWontResolve := !eligible || (ownResolved && ownInfo.Absent()) - // Inherit the album hash only when serving returns those exact bytes: a multi-disc track is - // served disc art, and an eligible-but-unresolved one still extracts its own embedded image. - if album, ok := albumInfos[mf.AlbumID]; ok && !album.Absent() { - if mf.DiscNumber == 0 && ownWontResolve { - mf.ItemImage = album.Image() - } - continue - } - // Mark absent only when serving would definitively yield a placeholder; disc art and - // still-extractable embedded art both keep a track requestable. - if mf.DiscNumber > 0 { - continue - } - if album, ok := albumInfos[mf.AlbumID]; ok && album.Absent() && ownWontResolve { - mf.ImageAbsent = true - } - } -} - -// hydrateCursor hydrates a streamed cursor in batches, avoiding a per-row query. -func hydrateCursor[T any](cursor iter.Seq2[T, error], hydrate func([]T)) iter.Seq2[T, error] { - return func(yield func(T, error) bool) { - buf := make([]T, 0, artworkBatchSize) - flush := func() bool { - hydrate(buf) - for i := range buf { - if !yield(buf[i], nil) { - return false - } - } - buf = buf[:0] - return true - } - for row, err := range cursor { - if err != nil { - var zero T - yield(zero, err) - return - } - buf = append(buf, row) - if len(buf) == artworkBatchSize && !flush() { - return - } - } - if len(buf) > 0 { - flush() - } - } -} - -// hydratePlaylistTrackArtwork hydrates the MediaFile embedded in each playlist track. -func hydratePlaylistTrackArtwork(ctx context.Context, db dbx.Builder, tracks model.PlaylistTracks) { - if len(tracks) == 0 { - return - } - mfs := make(model.MediaFiles, len(tracks)) - for i := range tracks { - mfs[i] = tracks[i].MediaFile - } - hydrateMediaFileArtwork(ctx, db, mfs) - for i := range tracks { - tracks[i].MediaFile = mfs[i] - } -} diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go deleted file mode 100644 index 89182d6cc..000000000 --- a/persistence/artwork_hydration_test.go +++ /dev/null @@ -1,773 +0,0 @@ -package persistence - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -// seedAnnotations gives each id distinct annotation values, writing them directly so that -// SetRating's average_rating update does not outlive the cleanup. -func seedAnnotations(itemType string, ids ...string) { - GinkgoHelper() - when := time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC) - for i, id := range ids { - _, err := GetDBXBuilder().NewQuery(`insert or replace into annotation - (user_id, item_id, item_type, play_count, play_date, rating, rated_at, starred, starred_at) - values ({:u}, {:id}, {:t}, {:pc}, {:d}, {:r}, {:d}, 1, {:d})`). - Bind(dbx.Params{"u": adminUser.ID, "id": id, "t": itemType, - "pc": i + 1, "r": 4 + i, "d": when.Add(time.Duration(i) * time.Hour)}).Execute() - Expect(err).ToNot(HaveOccurred()) - } - DeferCleanup(func() { - for _, id := range ids { - _, err := GetDBXBuilder().NewQuery("delete from annotation where user_id={:u} and item_type={:t} and item_id={:id}"). - Bind(dbx.Params{"u": adminUser.ID, "t": itemType, "id": id}).Execute() - Expect(err).ToNot(HaveOccurred()) - } - }) -} - -var _ = Describe("Artwork hydration", func() { - var ctx context.Context - var aw model.ArtworkRepository - - putInfo := func(kind, id, hash string) { - Expect(aw.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Hash: hash, - })).To(Succeed()) - } - - BeforeEach(func() { - clearArtworkTables() - DeferCleanup(clearArtworkTables) - ctx = request.WithUser(log.NewContext(context.Background()), adminUser) - aw = NewArtworkRepository(GetDBXBuilder()) - }) - - Describe("albums", func() { - var repo model.AlbumRepository - BeforeEach(func() { repo = NewAlbumRepository(GetDBXBuilder()) }) - - It("hydrates the found / known-absent / unresolved states", func() { - putInfo("al", albumSgtPeppers.ID, "althash11111111") - putInfo("al", albumAbbeyRoad.ID, "") - // albumRadioactivity: no row -> unresolved - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - byID := slice.ToMap(all, func(a model.Album) (string, model.Album) { return a.ID, a }) - - Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("althash11111111")) - Expect(byID[albumSgtPeppers.ID].ImageAbsent).To(BeFalse()) - Expect(byID[albumAbbeyRoad.ID].ImageHash).To(BeEmpty()) - Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue()) - Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty()) - Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse()) - }) - - It("hydrates Get", func() { - putInfo("al", albumSgtPeppers.ID, "gethash22222222") - got, err := repo.Get(ctx, albumSgtPeppers.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.ImageHash).To(Equal("gethash22222222")) - }) - - It("hydrates Search", func() { - putInfo("al", albumSgtPeppers.ID, "srchash33333333") - res, err := repo.Search(ctx, "Peppers", model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).ToNot(BeEmpty()) - Expect(res[0].ImageHash).To(Equal("srchash33333333")) - }) - - It("does not persist ImageHash/ImageAbsent on Put", func() { - al := albumSgtPeppers - al.ImageHash = "shouldnotpersist" - al.ImageAbsent = true - Expect(repo.(*albumRepository).Put(ctx, &al)).To(Succeed()) - - got, err := repo.Get(ctx, al.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.ImageHash).To(BeEmpty()) - Expect(got.ImageAbsent).To(BeFalse()) - }) - }) - - Describe("artists", func() { - var repo model.ArtistRepository - BeforeEach(func() { repo = NewArtistRepository(GetDBXBuilder()) }) - - It("hydrates the found / known-absent / unresolved states", func() { - putInfo("ar", artistBeatles.ID, "arhash444444444") - putInfo("ar", artistKraftwerk.ID, "") - // artistCJK: no row -> unresolved - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - byID := slice.ToMap(all, func(a model.Artist) (string, model.Artist) { return a.ID, a }) - - Expect(byID[artistBeatles.ID].ImageHash).To(Equal("arhash444444444")) - Expect(byID[artistBeatles.ID].ImageAbsent).To(BeFalse()) - Expect(byID[artistKraftwerk.ID].ImageHash).To(BeEmpty()) - Expect(byID[artistKraftwerk.ID].ImageAbsent).To(BeTrue()) - Expect(byID[artistCJK.ID].ImageHash).To(BeEmpty()) - Expect(byID[artistCJK.ID].ImageAbsent).To(BeFalse()) - }) - - It("hydrates Get", func() { - putInfo("ar", artistBeatles.ID, "arget5555555555") - got, err := repo.Get(ctx, artistBeatles.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.ImageHash).To(Equal("arget5555555555")) - }) - - It("hydrates Search", func() { - putInfo("ar", artistBeatles.ID, "arsrch666666666") - res, err := repo.Search(ctx, "Beatles", model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).ToNot(BeEmpty()) - Expect(res[0].ImageHash).To(Equal("arsrch666666666")) - }) - }) - - Describe("playlists", func() { - var repo model.PlaylistRepository - BeforeEach(func() { repo = NewPlaylistRepository(GetDBXBuilder()) }) - - It("hydrates the found / known-absent states", func() { - putInfo("pl", plsBest.ID, "plhash777777777") - putInfo("pl", plsCool.ID, "") - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - byID := slice.ToMap(all, func(p model.Playlist) (string, model.Playlist) { return p.ID, p }) - - Expect(byID[plsBest.ID].ImageHash).To(Equal("plhash777777777")) - Expect(byID[plsBest.ID].ImageAbsent).To(BeFalse()) - Expect(byID[plsCool.ID].ImageHash).To(BeEmpty()) - Expect(byID[plsCool.ID].ImageAbsent).To(BeTrue()) - }) - - It("hydrates Get", func() { - putInfo("pl", plsBest.ID, "plget8888888888") - got, err := repo.Get(ctx, plsBest.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.ImageHash).To(Equal("plget8888888888")) - }) - - It("hydrates the tracks reached through a playlist", func() { - Expect(aw.PutImage(ctx, &model.Artwork{Hash: "pltrackhash1234", Mime: "image/jpeg", BlurHash: "LPLBLURhash"})).To(Succeed()) - putInfo("al", songDayInALife.AlbumID, "pltrackhash1234") - - pls, err := repo.GetWithTracks(ctx, plsBest.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - tracks := pls.Tracks - Expect(tracks).ToNot(BeEmpty()) - byID := slice.ToMap(tracks, func(t model.PlaylistTrack) (string, model.PlaylistTrack) { return t.MediaFile.ID, t }) - Expect(byID).To(HaveKey(songDayInALife.ID)) - Expect(byID[songDayInALife.ID].AlbumImage.ImageHash).To(Equal("pltrackhash1234")) - Expect(byID[songDayInALife.ID].BlurHash).To(Equal("LPLBLURhash")) - - cursor, err := repo.Tracks(ctx, plsBest.ID, true).GetCursor(ctx) - Expect(err).ToNot(HaveOccurred()) - var streamed *model.PlaylistTrack - for t, err := range cursor { - Expect(err).ToNot(HaveOccurred()) - if t.MediaFile.ID == songDayInALife.ID { - streamed = &t - } - } - Expect(streamed).ToNot(BeNil()) - Expect(streamed.AlbumImage.ImageHash).To(Equal("pltrackhash1234"), - "the streamed cursor Jellyfin uses must hydrate too") - }) - }) - - Describe("radios", func() { - var repo model.RadioRepository - BeforeEach(func() { repo = NewRadioRepository(GetDBXBuilder()) }) - - It("hydrates the found / known-absent states", func() { - putInfo("ra", radioWithHomePage.ID, "rahash999999999") - putInfo("ra", radioWithoutHomePage.ID, "") - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - byID := slice.ToMap(all, func(rd model.Radio) (string, model.Radio) { return rd.ID, rd }) - - Expect(byID[radioWithHomePage.ID].ImageHash).To(Equal("rahash999999999")) - Expect(byID[radioWithHomePage.ID].ImageAbsent).To(BeFalse()) - Expect(byID[radioWithoutHomePage.ID].ImageHash).To(BeEmpty()) - Expect(byID[radioWithoutHomePage.ID].ImageAbsent).To(BeTrue()) - }) - - It("hydrates Get", func() { - putInfo("ra", radioWithHomePage.ID, "ragetaaaaaaaaaa") - got, err := repo.Get(ctx, radioWithHomePage.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.ImageHash).To(Equal("ragetaaaaaaaaaa")) - }) - }) - - Describe("mediafiles", func() { - var repo model.MediaFileRepository - - setCover := func(id string, v bool) { - _, err := GetDBXBuilder().NewQuery("UPDATE media_file SET has_cover_art={:v} WHERE id={:id}"). - Bind(dbx.Params{"v": v, "id": id}).Execute() - Expect(err).ToNot(HaveOccurred()) - } - - getByID := func() map[string]model.MediaFile { - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - return slice.ToMap(all, func(mf model.MediaFile) (string, model.MediaFile) { return mf.ID, mf }) - } - - BeforeEach(func() { - repo = NewMediaFileRepository(GetDBXBuilder()) - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableMediaFileCoverArt = true - }) - - It("resolves the embedded-eligible fallback matrix", func() { - setCover("1001", true) // eligible, own hash - setCover("1002", true) // eligible, but embedded art absent -> album - DeferCleanup(func() { setCover("1001", false); setCover("1002", false) }) - - putInfo("al", "101", "alh101xxxxxxxxxx") // song 1001's album (found) - putInfo("al", "102", "alh102xxxxxxxxxx") // song 1002's album (found) - putInfo("al", "103", "") // songs 1003/1004 album known-absent - putInfo("mf", "1001", "mfh1001xxxxxxxx") - putInfo("mf", "1002", "") // embedded resolved absent - - byID := getByID() - - Expect(byID["1001"].ImageHash).To(Equal("mfh1001xxxxxxxx")) - Expect(byID["1001"].ImageAbsent).To(BeFalse()) - Expect(byID["1002"].ImageHash).To(Equal("alh102xxxxxxxxxx")) - Expect(byID["1002"].ImageAbsent).To(BeFalse()) - Expect(byID["1003"].ImageHash).To(BeEmpty()) - Expect(byID["1003"].ImageAbsent).To(BeTrue()) - // 2002: not eligible, and its album has no row at all -> unresolved - Expect(byID["2002"].ImageHash).To(BeEmpty()) - Expect(byID["2002"].ImageAbsent).To(BeFalse()) - }) - - It("populates AlbumImage from hydrateArtwork regardless of which continue branch a track takes", func() { - setCover("1001", true) // eligible, resolves its own art -> own-art-wins continue - DeferCleanup(func() { setCover("1001", false) }) - - putInfo("al", "101", "alh101albimgxxxx") // 1001's album: own-art-wins branch - putInfo("al", "102", "alh102albimgxxxx") // 1002's album: single-disc inheritance branch - putInfo("al", "104", "") // 2002's album: known-absent, multi-disc branch - putInfo("mf", "1001", "mfh1001albimgxxx") - - byID := getByID() - - Expect(byID["1001"].ImageHash).To(Equal("mfh1001albimgxxx")) - Expect(byID["1001"].AlbumImage.ImageHash).To(Equal("alh101albimgxxxx")) - - Expect(byID["1002"].ImageHash).To(Equal("alh102albimgxxxx")) - Expect(byID["1002"].AlbumImage.ImageHash).To(Equal("alh102albimgxxxx")) - - Expect(byID["2002"].ImageHash).To(BeEmpty()) - Expect(byID["2002"].ImageAbsent).To(BeFalse()) - Expect(byID["2002"].AlbumImage.ImageAbsent).To(BeTrue()) - }) - - It("carries both hashes and the dimensions alongside the hash in the own-art and inherited branches", func() { - setCover("1001", true) // eligible, resolves its own art -> own-art-wins branch - DeferCleanup(func() { setCover("1001", false) }) - - Expect(aw.PutImage(ctx, &model.Artwork{Hash: "mfh1001blurxxxxx", Mime: "image/jpeg", BlurHash: "LTRACKblur", ThumbHash: "THtrack", Width: 640, Height: 480})).To(Succeed()) - Expect(aw.PutImage(ctx, &model.Artwork{Hash: "alh102blurxxxxxx", Mime: "image/jpeg", BlurHash: "LALBUMblur", ThumbHash: "THalbum", Width: 1200, Height: 800})).To(Succeed()) - putInfo("mf", "1001", "mfh1001blurxxxxx") - putInfo("al", "102", "alh102blurxxxxxx") // 1002's album: single-disc inheritance branch - - byID := getByID() - - Expect(byID["1001"].ImageHash).To(Equal("mfh1001blurxxxxx")) - Expect(byID["1001"].BlurHash).To(Equal("LTRACKblur")) - Expect(byID["1001"].ThumbHash).To(Equal("THtrack")) - Expect(byID["1001"].ImageWidth).To(Equal(640)) - Expect(byID["1001"].ImageHeight).To(Equal(480)) - - Expect(byID["1002"].ImageHash).To(Equal("alh102blurxxxxxx")) - Expect(byID["1002"].BlurHash).To(Equal("LALBUMblur")) - Expect(byID["1002"].ThumbHash).To(Equal("THalbum")) - Expect(byID["1002"].ImageWidth).To(Equal(1200)) - Expect(byID["1002"].ImageHeight).To(Equal(800)) - }) - - It("keeps an eligible file optimistic when its own art is unresolved, even if the album is absent", func() { - setCover("1004", true) - DeferCleanup(func() { setCover("1004", false) }) - putInfo("al", "103", "") // album known-absent - - byID := getByID() - - // 1004's own embedded art is still unresolved, so coverArt must stay requestable. - Expect(byID["1004"].ImageAbsent).To(BeFalse()) - Expect(byID["1004"].ImageHash).To(BeEmpty()) - // 1003 is not eligible, so it still inherits the album's absence. - Expect(byID["1003"].ImageAbsent).To(BeTrue()) - }) - - It("does not stamp a found album hash onto a multi-disc track (its dc- id is disc-served)", func() { - putInfo("al", "104", "alh104foundxxxxx") // songs 2002/2004 album is found - - byID := getByID() - - Expect(byID["2002"].ImageHash).To(BeEmpty()) - Expect(byID["2002"].ImageAbsent).To(BeFalse()) - }) - - It("keeps a multi-disc track requestable when its album is absent (disc art may resolve)", func() { - putInfo("al", "104", "") // songs 2002/2004 album known-absent - - byID := getByID() - - Expect(byID["2002"].ImageAbsent).To(BeFalse()) - Expect(byID["2002"].ImageHash).To(BeEmpty()) - }) - - // serveMediaFile serves this track's own embedded art, so the album's hash would - // advertise a content-version for bytes nobody will serve. - It("leaves the hash bare for an eligible file whose own art is unresolved", func() { - setCover("1004", true) - DeferCleanup(func() { setCover("1004", false) }) - putInfo("al", "103", "alh103found11111") - - byID := getByID() - Expect(byID["1004"].ImageAbsent).To(BeFalse()) - Expect(byID["1004"].ImageHash).To(BeEmpty()) - Expect(byID["1004"].AlbumImage.ImageHash).To(Equal("alh103found11111"), - "the album's own hash still hydrates, for AlbumCoverArtID") - }) - - It("uses album info for an eligible file when EnableMediaFileCoverArt is off", func() { - conf.Server.EnableMediaFileCoverArt = false - setCover("1001", true) - DeferCleanup(func() { setCover("1001", false) }) - - putInfo("al", "101", "alh101offxxxxxxx") - putInfo("mf", "1001", "mfh1001offxxxxx") - - byID := getByID() - Expect(byID["1001"].ImageHash).To(Equal("alh101offxxxxxxx")) - Expect(byID["1001"].ImageAbsent).To(BeFalse()) - }) - - It("hydrates Search", func() { - putInfo("al", "101", "alsrchhhhhhhhhhh") - res, err := repo.Search(ctx, "A Day In A Life", model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).ToNot(BeEmpty()) - Expect(res[0].ImageHash).To(Equal("alsrchhhhhhhhhhh")) - }) - }) - - Describe("GetCursor", func() { - var albumRepo model.AlbumRepository - var artistRepo model.ArtistRepository - var playlistRepo model.PlaylistRepository - var onlyAlbums, onlyArtists, onlyPlaylists squirrel.Eq - - scoped := func(opts model.QueryOptions, only squirrel.Eq) model.QueryOptions { - if opts.Filters == nil { - opts.Filters = only - } else { - opts.Filters = squirrel.And{only, opts.Filters} - } - return opts - } - - BeforeEach(func() { - albumRepo = NewAlbumRepository(GetDBXBuilder()) - artistRepo = NewArtistRepository(GetDBXBuilder()) - playlistRepo = NewPlaylistRepository(GetDBXBuilder()) - // Other specs leave rows behind, so scope every cursor spec to the fixtures. - onlyAlbums = squirrel.Eq{"album.id": []string{albumSgtPeppers.ID, albumAbbeyRoad.ID, - albumRadioactivity.ID, albumMultiDisc.ID, albumCJK.ID, albumPunctuation.ID}} - onlyArtists = squirrel.Eq{"artist.id": []string{artistKraftwerk.ID, artistBeatles.ID, - artistCJK.ID, artistPunctuation.ID}} - // Both fixture playlists share an owner, leaving the owner_name sort a single value to - // order by; this one is also private, which the non-admin visibility spec needs. - foreign := model.Playlist{Name: "Foreign", OwnerID: thirdUser.ID, OwnerName: thirdUser.UserName} - Expect(playlistRepo.Put(ctx, &foreign)).To(Succeed()) - DeferCleanup(func() { Expect(playlistRepo.Delete(ctx, foreign.ID)).To(Succeed()) }) - onlyPlaylists = squirrel.Eq{"playlist.id": []string{plsBest.ID, plsCool.ID, foreign.ID}} - - // The suite annotates a single album and artist, leaving the annotation-backed sorts - // nothing to order. - seedAnnotations("album", albumSgtPeppers.ID, albumAbbeyRoad.ID) - seedAnnotations("artist", artistKraftwerk.ID, artistCJK.ID) - - Expect(aw.PutImage(ctx, &model.Artwork{ - Hash: "curhash11111111", Mime: "image/jpeg", BlurHash: "LEHV6nWB2yk8", - })).To(Succeed()) - putInfo("al", albumSgtPeppers.ID, "curhash11111111") - putInfo("al", albumAbbeyRoad.ID, "") - putInfo("ar", artistBeatles.ID, "curhash11111111") - putInfo("pl", plsBest.ID, "curhash11111111") - }) - - It("hydrates every streamed album, like GetAll", func() { - opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums} - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - - got := collectCursor(albumRepo.GetCursor(ctx, opts)) - - Expect(got).To(ConsistOf(want)) - byID := map[string]model.Album{} - for _, al := range got { - byID[al.ID] = al - } - Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("curhash11111111")) - Expect(byID[albumSgtPeppers.ID].BlurHash).To(Equal("LEHV6nWB2yk8")) - Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue()) - Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty()) - Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse()) - }) - - It("hydrates every streamed artist, like GetAll", func() { - opts := model.QueryOptions{Sort: "name", Filters: onlyArtists} - want, err := artistRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - - got := collectCursor(artistRepo.GetCursor(ctx, opts)) - - Expect(got).To(ConsistOf(want)) - Expect(slice.Map(got, func(a model.Artist) string { return a.ImageHash })). - To(ContainElement("curhash11111111")) - }) - - It("hydrates every streamed playlist, like GetAll", func() { - opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} - want, err := playlistRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - - got := collectCursor(playlistRepo.GetCursor(ctx, opts)) - - Expect(got).To(ConsistOf(want)) - Expect(slice.Map(got, func(p model.Playlist) string { return p.ImageHash })). - To(ContainElement("curhash11111111")) - }) - - It("honors Max and Offset exactly once", func() { - opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums, Max: 2, Offset: 1} - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).To(HaveLen(2)) - - got := collectCursor(albumRepo.GetCursor(ctx, opts)) - - Expect(slice.Map(got, func(a model.Album) string { return a.ID })). - To(Equal(slice.Map(want, func(a model.Album) string { return a.ID }))) - }) - - // The sorts the Jellyfin list endpoints issue; comparing sort keys, not ids, keeps ties out. - DescribeTable("orders albums like GetAll", - func(opts model.QueryOptions, key func(model.Album) string) { - opts = scoped(opts, onlyAlbums) - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - - got := collectCursor(albumRepo.GetCursor(ctx, opts)) - - Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) - Expect(slice.Map(got, func(a model.Album) string { return a.ID })). - To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) - }, - Entry("by name", model.QueryOptions{Sort: "name"}, - func(a model.Album) string { return a.OrderAlbumName }), - Entry("by artist", model.QueryOptions{Sort: "artist"}, - func(a model.Album) string { return a.OrderAlbumArtistName }), - Entry("by recently added", model.QueryOptions{Sort: "recently_added", Order: "desc"}, - func(a model.Album) string { return fmt.Sprint(a.CreatedAt) }), - Entry("by year", model.QueryOptions{Sort: "max_year"}, - func(a model.Album) string { return fmt.Sprint(a.MaxYear) }), - Entry("by play count", model.QueryOptions{Sort: "play_count", Order: "desc"}, - func(a model.Album) string { return fmt.Sprint(a.PlayCount) }), - Entry("by last played", model.QueryOptions{Sort: "play_date", Order: "desc"}, - func(a model.Album) string { return fmt.Sprint(a.PlayDate) }), - Entry("by rating", model.QueryOptions{Sort: "rating", Order: "desc"}, - func(a model.Album) string { return fmt.Sprint(a.Rating) }), - Entry("starred only", model.QueryOptions{Sort: "starred_at", Order: "desc", - Filters: squirrel.Eq{"starred": true}}, - func(a model.Album) string { return fmt.Sprint(a.StarredAt) }), - ) - - DescribeTable("orders artists like GetAll", - func(opts model.QueryOptions, key func(model.Artist) string) { - opts = scoped(opts, onlyArtists) - want, err := artistRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - - got := collectCursor(artistRepo.GetCursor(ctx, opts)) - - Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) - Expect(slice.Map(got, func(a model.Artist) string { return a.ID })). - To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) - }, - Entry("by name", model.QueryOptions{Sort: "name"}, - func(a model.Artist) string { return a.OrderArtistName }), - Entry("by album count", model.QueryOptions{Sort: "album_count", Order: "desc"}, - func(a model.Artist) string { return fmt.Sprint(a.AlbumCount) }), - Entry("by song count", model.QueryOptions{Sort: "song_count", Order: "desc"}, - func(a model.Artist) string { return fmt.Sprint(a.SongCount) }), - Entry("by play count", model.QueryOptions{Sort: "play_count", Order: "desc"}, - func(a model.Artist) string { return fmt.Sprint(a.PlayCount) }), - Entry("starred only", model.QueryOptions{Sort: "starred_at", Order: "desc", - Filters: squirrel.Eq{"starred": true}}, - func(a model.Artist) string { return fmt.Sprint(a.StarredAt) }), - ) - - DescribeTable("orders playlists like GetAll", - func(opts model.QueryOptions, key func(model.Playlist) string) { - opts = scoped(opts, onlyPlaylists) - want, err := playlistRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - - got := collectCursor(playlistRepo.GetCursor(ctx, opts)) - - Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) - Expect(slice.Map(got, func(p model.Playlist) string { return p.ID })). - To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) - }, - Entry("by name", model.QueryOptions{Sort: "name"}, - func(p model.Playlist) string { return p.Name }), - Entry("by creation date", model.QueryOptions{Sort: "created_at", Order: "desc"}, - func(p model.Playlist) string { return fmt.Sprint(p.CreatedAt) }), - Entry("by owner", model.QueryOptions{Sort: "owner_name"}, - func(p model.Playlist) string { return p.OwnerName }), - ) - - It("streams every album exactly once when sorted randomly", func() { - opts := model.QueryOptions{Sort: "random", Filters: onlyAlbums} - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - - got := collectCursor(albumRepo.GetCursor(ctx, opts)) - - Expect(slice.Map(got, func(a model.Album) string { return a.ID })). - To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) - }) - - It("keeps a non-admin from streaming another user's private playlists", func() { - otherCtx := request.WithUser(log.NewContext(context.Background()), regularUser) - repo := NewPlaylistRepository(GetDBXBuilder()) - opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} - - // Both phases must filter on their own: the id pre-pass and the chunk fetch. - Expect(repo.(*playlistRepository).getAllIDs(otherCtx, opts)).To(ConsistOf(plsBest.ID)) - all, err := repo.GetAll(otherCtx, model.QueryOptions{Filters: onlyPlaylists}) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(all, func(p model.Playlist) string { return p.ID })).To(ConsistOf(plsBest.ID)) - - got := collectCursor(repo.GetCursor(otherCtx, opts)) - - Expect(slice.Map(got, func(p model.Playlist) string { return p.Name })). - To(ConsistOf(plsBest.Name)) - }) - }) - - Describe("GetCursorWithArtwork", func() { - var mfRepo model.MediaFileRepository - var onlySongs squirrel.Eq - - BeforeEach(func() { - mfRepo = NewMediaFileRepository(GetDBXBuilder()) - putInfo("al", albumSgtPeppers.ID, "curhash11111111") - // Distinct titles only: other fixture songs share titles (e.g. "Antenna" x3), which - // would make the positional comparisons against GetAll pass by tie-order coincidence. - onlySongs = squirrel.Eq{"media_file.id": []string{songDayInALife.ID, songComeTogether.ID, - songRadioactivity.ID, songAntenna.ID, songDisc1Track01.ID, songCJK.ID, songPunctuation.ID}} - }) - - It("hydrates artwork onto every streamed track, unlike GetCursor", func() { - opts := model.QueryOptions{Sort: "title", Filters: onlySongs} - want, err := mfRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - - cursor, err := mfRepo.GetCursorWithArtwork(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - var got model.MediaFiles - cursor(func(mf model.MediaFile, err error) bool { - Expect(err).ToNot(HaveOccurred()) - got = append(got, mf) - return true - }) - - Expect(got).To(HaveLen(len(want))) - for i := range want { - Expect(got[i].ID).To(Equal(want[i].ID)) - Expect(got[i].ImageHash).To(Equal(want[i].ImageHash)) - Expect(got[i].ImageAbsent).To(Equal(want[i].ImageAbsent)) - Expect(got[i].AlbumImage.ImageHash).To(Equal(want[i].AlbumImage.ImageHash)) - Expect(got[i].AlbumImage.BlurHash).To(Equal(want[i].AlbumImage.BlurHash)) - } - Expect(want).To(ContainElement(HaveField("AlbumImage.ImageHash", Not(BeEmpty()))), - "fixture must include at least one track with album artwork, or this proves nothing") - }) - - It("leaves the scanner's GetCursor unhydrated", func() { - cursor, err := mfRepo.GetCursor(ctx, model.QueryOptions{Sort: "title"}) - Expect(err).ToNot(HaveOccurred()) - var seen int - cursor(func(mf model.MediaFile, err error) bool { - Expect(err).ToNot(HaveOccurred()) - Expect(mf.AlbumImage.ImageHash).To(BeEmpty(), "GetCursor must stay unhydrated for the scanner") - seen++ - return true - }) - Expect(seen).To(BeNumerically(">", 0)) - }) - - It("streams the same ids in the same order as GetAll", func() { - opts := model.QueryOptions{Sort: "title", Filters: onlySongs} - want, err := mfRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - - got := collectCursor(mfRepo.GetCursorWithArtwork(ctx, opts)) - - Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })). - To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID }))) - }) - - It("honors Max and Offset exactly once", func() { - opts := model.QueryOptions{Sort: "title", Filters: onlySongs, Max: 2, Offset: 1} - want, err := mfRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).To(HaveLen(2)) - - got := collectCursor(mfRepo.GetCursorWithArtwork(ctx, opts)) - - Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })). - To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID }))) - }) - }) - - Describe("chunkOptions", func() { - It("carries Sort, Order and the caller's filters, but never Max/Offset", func() { - base := model.QueryOptions{Sort: "name", Order: "desc", Max: 10, Offset: 20, - Filters: squirrel.Eq{"missing": false}} - - got := chunkOptions([]model.QueryOptions{base}, "album.id")([]string{"al-1", "al-2"}) - - Expect(got.Max).To(BeZero()) - Expect(got.Offset).To(BeZero()) - Expect(got.Sort).To(Equal("name")) - Expect(got.Order).To(Equal("desc")) - Expect(got.Filters).To(Equal(squirrel.And{ - base.Filters, squirrel.Eq{"album.id": []string{"al-1", "al-2"}}, - })) - }) - - It("filters by ids alone when the caller passed no filters", func() { - got := chunkOptions(nil, "artist.id")([]string{"ar-1"}) - Expect(got.Filters).To(Equal(squirrel.Eq{"artist.id": []string{"ar-1"}})) - }) - }) - - Describe("streamByIDs", func() { - It("fetches in chunks and yields every row in order", func() { - ids := make([]string, artworkChunkSize+3) - for i := range ids { - ids[i] = fmt.Sprintf("id-%d", i) - } - var chunks [][]string - got := collectCursor(streamByIDs(ids, func(chunk []string) ([]string, error) { - chunks = append(chunks, chunk) - return chunk, nil - }), nil) - - Expect(chunks).To(HaveLen(2)) - Expect(chunks[0]).To(HaveLen(artworkChunkSize)) - Expect(chunks[1]).To(HaveLen(3)) - Expect(got).To(Equal(ids)) - }) - - It("yields the fetch error and stops", func() { - ids := make([]string, artworkChunkSize+1) - calls := 0 - var errs []error - for _, err := range streamByIDs(ids, func(chunk []string) ([]string, error) { - calls++ - return nil, errors.New("boom") - }) { - errs = append(errs, err) - } - - Expect(calls).To(Equal(1)) - Expect(errs).To(HaveLen(1)) - Expect(errs[0]).To(MatchError("boom")) - }) - - It("stops fetching when the consumer breaks", func() { - ids := make([]string, artworkChunkSize+1) - calls := 0 - for range streamByIDs(ids, func(chunk []string) ([]string, error) { - calls++ - return chunk, nil - }) { - break - } - - Expect(calls).To(Equal(1)) - }) - }) - - Describe("applyItemImage", func() { - It("copies hash, absence, blurhash and dimensions onto the item", func() { - infos := map[string]model.ItemArtworkInfo{ - "al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8", ThumbHash: "1QcSHQRn", Width: 1200, Height: 800}, - } - var img model.ItemImage - applyItemImage(infos, "al-1", &img) - Expect(img.ImageHash).To(Equal("0123456789abcdef")) - Expect(img.ImageAbsent).To(BeFalse()) - Expect(img.BlurHash).To(Equal("LEHV6nWB2yk8")) - Expect(img.ThumbHash).To(Equal("1QcSHQRn")) - Expect(img.ImageWidth).To(Equal(1200)) - Expect(img.ImageHeight).To(Equal(800)) - }) - - It("marks a hashless entry absent and carries no blurhash", func() { - infos := map[string]model.ItemArtworkInfo{"al-2": {ItemID: "al-2"}} - var img model.ItemImage - applyItemImage(infos, "al-2", &img) - Expect(img.ImageAbsent).To(BeTrue()) - Expect(img.BlurHash).To(BeEmpty()) - Expect(img.ThumbHash).To(BeEmpty()) - }) - - It("leaves an unresolved item zero-valued", func() { - var img model.ItemImage - applyItemImage(map[string]model.ItemArtworkInfo{}, "al-3", &img) - Expect(img.ImageHash).To(BeEmpty()) - Expect(img.ImageAbsent).To(BeFalse()) - Expect(img.BlurHash).To(BeEmpty()) - }) - }) -}) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go deleted file mode 100644 index 321fe8a95..000000000 --- a/persistence/artwork_queue_repository.go +++ /dev/null @@ -1,235 +0,0 @@ -package persistence - -import ( - "cmp" - "context" - "fmt" - "slices" - "strings" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/pocketbase/dbx" -) - -// Keeps each multi-row insert under SQLite's bind-variable limit (at most 7 vars per row). -const enqueueChunkSize = 100 - -// Every insert writes these, in this order; the INSERT..SELECT forms must project them to match. -// DequeueBatch also selects exactly these, to leave the drain's rows free of the trace it never reads. -var enqueueColumns = []string{"item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at"} - -type artworkQueueRepository struct { - sqlRepository -} - -func NewArtworkQueueRepository(db dbx.Builder) model.ArtworkQueueRepository { - r := &artworkQueueRepository{} - r.db = db - r.tableName = "artwork_queue" - return r -} - -func (r *artworkQueueRepository) Get(ctx context.Context, kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { - var res model.ArtworkQueueItem - err := r.queryOne(ctx, Select("*").From(r.tableName). - Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}), &res) - if err != nil { - return nil, err - } - return &res, nil -} - -// Enqueue starts a fresh lifecycle: it resets enqueued_at (so a fresh request does not inherit an old -// row's spent retry budget) and clears trace (so explain does not show a prior failure at attempts 0). -func (r *artworkQueueRepository) Enqueue(ctx context.Context, items ...model.ArtworkQueueItem) error { - return r.enqueue(ctx, `ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET - priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at, - attempts = 0, enqueued_at = excluded.enqueued_at, trace = '[]'`, items) -} - -func (r *artworkQueueRepository) EnqueuePreservingBackoff(ctx context.Context, items ...model.ArtworkQueueItem) error { - return r.enqueue(ctx, `ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET - priority = MAX(priority, excluded.priority)`, items) -} - -func (r *artworkQueueRepository) EnqueueAllMissing(ctx context.Context, kind model.Kind, priority int) (int64, error) { - entityTable, ok := artworkOwnerTables[kind] - if !ok { - return 0, fmt.Errorf("artwork queue: no entity table for kind %q", kind.Prefix()) - } - now := time.Now() - return r.insertIfNotQueued(ctx, "", `SELECT ?, id, ?, ?, 0, ?, ? - FROM `+entityTable+` - WHERE id NOT IN (SELECT item_id FROM `+itemArtworkTable+` WHERE item_kind = ?)`, - kind.Prefix(), model.ImageTypePrimary, priority, now, now, kind.Prefix()) -} - -func (r *artworkQueueRepository) EnqueueIfMissing(ctx context.Context, items ...model.ArtworkQueueItem) error { - now := time.Now() - for chunk := range slices.Chunk(items, enqueueChunkSize) { - rows := make([]string, 0, len(chunk)) - args := make([]any, 0, len(chunk)*4+2) - for _, it := range chunk { - rows = append(rows, "(?,?,?,?)") - args = append(args, it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority) - } - args = append(args, now, now) - _, err := r.insertIfNotQueued(ctx, - `WITH new_items(item_kind, item_id, image_type, priority) AS (VALUES `+strings.Join(rows, ",")+`) `, - `SELECT n.item_kind, n.item_id, n.image_type, n.priority, 0, ?, ? - FROM new_items n - WHERE NOT EXISTS ( - SELECT 1 FROM `+itemArtworkTable+` ia - WHERE ia.item_kind = n.item_kind AND ia.item_id = n.item_id AND ia.image_type = n.image_type)`, - args...) - if err != nil { - return err - } - } - return nil -} - -// DO NOTHING is deliberate: a recheck must not bump the priority or retry_at of an already-queued item. -const skipIfQueued = ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING` - -// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. -func (r *artworkQueueRepository) insertIfNotQueued(ctx context.Context, with, sql string, args ...any) (int64, error) { - return r.executeSQL(ctx, Expr(with+`INSERT INTO `+r.tableName+ - ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...)) -} - -// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the -// absent state, and ArtworkSourceFailed the absent states that gave up. Several are a union, so -// asking for both absent and failed is just absent. -func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer { - f := And{Eq{"item_kind": kind.Prefix()}} - if len(sources) == 0 { - return f - } - stored := slices.DeleteFunc(slices.Clone(sources), func(s string) bool { return s == model.ArtworkSourceFailed }) - var match Or - if len(stored) > 0 { - match = append(match, Eq{"source": stored}) - } - if len(stored) != len(sources) { - match = append(match, And{Eq{"hash": ""}, NotEq{"last_failure": ""}}) - } - return append(f, match) -} - -func (r *artworkQueueRepository) CountBySource(ctx context.Context, kind model.Kind, sources []string) (int64, error) { - var res struct{ Count int64 } - err := r.queryOne(ctx, Select("count(*) as count").From(itemArtworkTable). - Where(artworkSourceFilter(kind, sources)), &res) - return res.Count, err -} - -func (r *artworkQueueRepository) SourcesInUse(ctx context.Context, kind model.Kind) ([]string, error) { - var res []struct{ Source string } - err := r.queryAll(ctx, Select("distinct source").From(itemArtworkTable). - Where(Eq{"item_kind": kind.Prefix()}), &res) - if err != nil { - return nil, err - } - return slice.Map(res, func(s struct{ Source string }) string { return s.Source }), nil -} - -// EnqueueBySource deliberately leaves item_artwork alone: clearing state in bulk would blank the -// library's artwork until every item is resolved again. -func (r *artworkQueueRepository) EnqueueBySource(ctx context.Context, kind model.Kind, sources []string, priority int) (int64, error) { - now := time.Now() - sel := Select("item_kind", "item_id", "image_type"). - Column(Expr("?", priority)).Column("0").Column(Expr("?", now)).Column(Expr("?", now)). - From(itemArtworkTable).Where(artworkSourceFilter(kind, sources)) - return r.executeSQL(ctx, Insert(r.tableName).Columns(enqueueColumns...).Select(sel).Suffix(skipIfQueued)) -} - -func (r *artworkQueueRepository) enqueue(ctx context.Context, conflict string, items []model.ArtworkQueueItem) error { - now := time.Now() - for chunk := range slices.Chunk(items, enqueueChunkSize) { - ins := Insert(r.tableName).Columns(enqueueColumns...) - for _, it := range chunk { - ins = ins.Values(it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority, 0, now, now) - } - ins = ins.Suffix(conflict) - if _, err := r.executeSQL(ctx, ins); err != nil { - return err - } - } - return nil -} - -func (r *artworkQueueRepository) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) { - sel := Select(enqueueColumns...).From(r.tableName). - Where(LtOrEq{"retry_at": time.Now()}). - OrderBy("priority DESC", "enqueued_at ASC"). - Limit(uint64(n)) - if len(kinds) > 0 { - sel = sel.Where(Eq{"item_kind": kinds}) - } - var res []model.ArtworkQueueItem - err := r.queryAll(ctx, sel, &res) - return res, err -} - -func (r *artworkQueueRepository) MarkFailedIfUnchanged(ctx context.Context, kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { - upd := Update(r.tableName). - Set("attempts", Expr("attempts + 1")). - Set("retry_at", retryAt). - Set("trace", trace). - Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt}) - _, err := r.executeSQL(ctx, upd) - return err -} - -func (r *artworkQueueRepository) DeleteIfUnchanged(ctx context.Context, kind, id, imageType string, retryAt time.Time) error { - return r.delete(ctx, Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": retryAt}) -} - -func (r *artworkQueueRepository) PurgeDangling(ctx context.Context) (int64, error) { - return purgeDangling(ctx, r.sqlRepository) -} - -// artworkQueueFilter returns no conditions for an empty filter, so an unfiltered DELETE keeps -// SQLite's truncate path. It ignores retry_at: a backing-off row is pending work too. -func artworkQueueFilter(kinds []model.Kind, priorities []int) And { - var f And - if len(kinds) > 0 { - f = append(f, Eq{"item_kind": model.KindPrefixes(kinds)}) - } - if len(priorities) > 0 { - f = append(f, Eq{"priority": priorities}) - } - return f -} - -// CountQueued shares its filter with PurgeQueued, so a preview cannot count rows the delete misses. -func (r *artworkQueueRepository) CountQueued(ctx context.Context, kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { - sel := Select("item_kind", "priority", "count(*) as count").From(r.tableName). - GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc") - if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { - sel = sel.Where(f) - } - var res []model.ArtworkQueueStat - err := r.queryAll(ctx, sel, &res) - return res, err -} - -func (r *artworkQueueRepository) PurgeQueued(ctx context.Context, kinds []model.Kind, priorities []int) (int64, error) { - del := Delete(r.tableName) - if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { - del = del.Where(f) - } - return r.executeSQL(ctx, del) -} - -func (r *artworkQueueRepository) Count(ctx context.Context) (int64, error) { - var res struct{ Count int64 } - err := r.queryOne(ctx, Select("count(*) as count").From(r.tableName), &res) - return res.Count, err -} - -var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go deleted file mode 100644 index 6ce4be8e2..000000000 --- a/persistence/artwork_queue_repository_test.go +++ /dev/null @@ -1,516 +0,0 @@ -package persistence - -import ( - "context" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -var _ = Describe("ArtworkQueueRepository", func() { - var repo model.ArtworkQueueRepository - var ctx context.Context - - item := func(kind, id string, prio int) model.ArtworkQueueItem { - return model.ArtworkQueueItem{ItemKind: kind, ItemID: id, - ImageType: model.ImageTypePrimary, Priority: prio} - } - - // Writes the post-failure state directly: the production path, MarkFailedIfUnchanged, needs - // a retry_at only a dequeue can hand it. - backOff := func(kind, id string, retryAt time.Time) { - GinkgoHelper() - r := repo.(*artworkQueueRepository) - _, err := r.executeSQL(ctx, squirrel.Update(r.tableName). - Set("attempts", squirrel.Expr("attempts + 1")). - Set("retry_at", retryAt). - Where(squirrel.Eq{"item_kind": kind, "item_id": id, "image_type": model.ImageTypePrimary})) - Expect(err).ToNot(HaveOccurred()) - } - - remove := func(kind, id string) { - GinkgoHelper() - r := repo.(*artworkQueueRepository) - Expect(r.delete(ctx, squirrel.Eq{"item_kind": kind, "item_id": id, "image_type": model.ImageTypePrimary})).To(Succeed()) - } - - BeforeEach(func() { - ctx = GinkgoT().Context() - clearArtworkTables() - DeferCleanup(clearArtworkTables) - repo = NewArtworkQueueRepository(GetDBXBuilder()) - }) - - It("enqueues and dequeues by priority then FIFO", func() { - Expect(repo.Enqueue(ctx, item("al", "low", model.ArtworkPriorityBackfill))).To(Succeed()) - Expect(repo.Enqueue(ctx, item("ar", "high", model.ArtworkPriorityBump))).To(Succeed()) - - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(2)) - Expect(got[0].ItemID).To(Equal("high")) - }) - - It("Get returns a queued row, including one still backing off", func() { - Expect(repo.Enqueue(ctx, item("ar", "g1", model.ArtworkPriorityScan))).To(Succeed()) - backOff("ar", "g1", time.Now().Add(time.Hour)) - - got, err := repo.Get(ctx, model.KindArtistArtwork, "g1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Priority).To(Equal(model.ArtworkPriorityScan)) - Expect(got.Attempts).To(Equal(1)) - Expect(got.RetryAt).To(BeTemporally(">", time.Now())) - }) - - It("Get reports ErrNotFound when the item is not queued", func() { - _, err := repo.Get(ctx, model.KindArtistArtwork, "nope", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("keeps the higher priority on duplicate enqueue", func() { - Expect(repo.Enqueue(ctx, item("al", "a1", model.ArtworkPriorityBump))).To(Succeed()) - Expect(repo.Enqueue(ctx, item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) - got, _ := repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) - }) - - It("EnqueuePreservingBackoff raises priority without resetting a backing-off row's retry_at", func() { - Expect(repo.Enqueue(ctx, item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) - backOff("al", "b1", time.Now().Add(time.Hour)) - Expect(repo.DequeueBatch(ctx, 10)).To(BeEmpty()) - - Expect(repo.EnqueuePreservingBackoff(ctx, item("al", "b1", model.ArtworkPriorityBump))).To(Succeed()) - Expect(repo.DequeueBatch(ctx, 10)).To(BeEmpty(), "bump must not reset retry_at") - - // Enqueue (scan/manual), by contrast, resets retry_at and makes it eligible now. - Expect(repo.Enqueue(ctx, item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) - got, _ := repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "bump's higher priority is preserved") - }) - - It("EnqueuePreservingBackoff inserts a brand-new row eligible immediately", func() { - Expect(repo.EnqueuePreservingBackoff(ctx, item("ar", "n1", model.ArtworkPriorityBump))).To(Succeed()) - got, _ := repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(got[0].ItemID).To(Equal("n1")) - }) - - It("hides failed items until retry_at", func() { - Expect(repo.Enqueue(ctx, item("al", "f1", model.ArtworkPriorityScan))).To(Succeed()) - backOff("al", "f1", time.Now().Add(time.Hour)) - - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(BeEmpty()) - - backOff("al", "f1", time.Now().Add(-time.Minute)) - got, _ = repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(got[0].Attempts).To(Equal(2)) - }) - - It("MarkFailedIfUnchanged applies backoff only while retry_at is unchanged", func() { - Expect(repo.Enqueue(ctx, item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) - // Anchor retry_at in the past so it can never collide with the re-enqueue's now. - backOff("al", "m1", time.Now().Add(-time.Hour)) - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(1)) - original := got[0].RetryAt - - // A concurrent scan re-enqueues, resetting retry_at to now. - Expect(repo.Enqueue(ctx, item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) - - future := time.Now().Add(48 * time.Hour) - Expect(repo.MarkFailedIfUnchanged(ctx, "al", "m1", model.ImageTypePrimary, original, future, "[]")).To(Succeed()) - got, _ = repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible") - Expect(got[0].Attempts).To(BeZero(), "re-enqueue clears attempts, and the stale failure must not bump them") - current := got[0].RetryAt - - Expect(repo.MarkFailedIfUnchanged(ctx, "al", "m1", model.ImageTypePrimary, current, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) - got, _ = repo.DequeueBatch(ctx, 10) - Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at") - all, _ := repo.Count(ctx) - Expect(all).To(Equal(int64(1))) - }) - - It("Enqueue clears a prior lifecycle's failure trace; EnqueuePreservingBackoff keeps it", func() { - // Fail an attempt so the queue row carries a failure trace. - Expect(repo.Enqueue(ctx, item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) - backOff("al", "t1", time.Now().Add(-time.Hour)) - got, _ := repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - future := time.Now().Add(48 * time.Hour) - Expect(repo.MarkFailedIfUnchanged(ctx, "al", "t1", model.ImageTypePrimary, got[0].RetryAt, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) - - // A continuation of the same lifecycle must retain the trace. - Expect(repo.EnqueuePreservingBackoff(ctx, item("al", "t1", model.ArtworkPriorityBump))).To(Succeed()) - kept, err := repo.Get(ctx, model.KindAlbumArtwork, "t1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(kept.Trace).To(Equal(`[{"c":"read","o":"error"}]`)) - - // A fresh Enqueue resets attempts to 0, so the stale failure trace must be cleared with it. - Expect(repo.Enqueue(ctx, item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) - fresh, err := repo.Get(ctx, model.KindAlbumArtwork, "t1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(fresh.Attempts).To(BeZero()) - Expect(fresh.Trace).To(Equal("[]"), "a fresh lifecycle has no last-attempt trace") - }) - - It("Enqueue restarts the retry budget an existing row had spent", func() { - Expect(repo.Enqueue(ctx, item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) - backOff("al", "e1", time.Now().Add(-time.Hour)) - stale := time.Now().Add(-48 * time.Hour) - _, err := GetDBXBuilder().NewQuery("UPDATE artwork_queue SET enqueued_at = {:t} WHERE item_id = 'e1'"). - Bind(dbx.Params{"t": stale}).Execute() - Expect(err).ToNot(HaveOccurred()) - - Expect(repo.Enqueue(ctx, item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) - - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(1)) - Expect(got[0].EnqueuedAt).To(BeTemporally("~", time.Now(), time.Minute), - "a re-request must not inherit a spent 12h window and give up on its first attempt") - Expect(got[0].Attempts).To(BeZero()) - }) - - It("deletes on completion and counts", func() { - Expect(repo.Enqueue(ctx, item("al", "c1", 0))).To(Succeed()) - n, _ := repo.Count(ctx) - Expect(n).To(Equal(int64(1))) - remove("al", "c1") - n, _ = repo.Count(ctx) - Expect(n).To(BeZero()) - }) - - It("DeleteIfUnchanged deletes only while retry_at is unchanged", func() { - Expect(repo.Enqueue(ctx, item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) - // Anchor retry_at in the past so it can never collide with the re-enqueue's now. - backOff("al", "d1", time.Now().Add(-time.Hour)) - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(1)) - original := got[0].RetryAt - - // A concurrent scan re-enqueues, resetting retry_at to now. - Expect(repo.Enqueue(ctx, item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) - - // Deleting with the stale retry_at is a no-op: the re-enqueued row survives. - Expect(repo.DeleteIfUnchanged(ctx, "al", "d1", model.ImageTypePrimary, original)).To(Succeed()) - n, _ := repo.Count(ctx) - Expect(n).To(Equal(int64(1))) - - got, _ = repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(repo.DeleteIfUnchanged(ctx, "al", "d1", model.ImageTypePrimary, got[0].RetryAt)).To(Succeed()) - n, _ = repo.Count(ctx) - Expect(n).To(BeZero()) - }) - - It("purges queue rows whose entity no longer exists, per kind", func() { - Expect(repo.Enqueue(ctx, - item("al", albumSgtPeppers.ID, model.ArtworkPriorityScan), - item("al", "no-such-album", model.ArtworkPriorityScan), - item("ar", artistKraftwerk.ID, model.ArtworkPriorityScan), - item("ar", "no-such-artist", model.ArtworkPriorityScan), - item("pl", plsBest.ID, model.ArtworkPriorityScan), - item("pl", "no-such-playlist", model.ArtworkPriorityScan), - item("ra", radioWithHomePage.ID, model.ArtworkPriorityScan), - item("ra", "no-such-radio", model.ArtworkPriorityScan), - item("mf", songDayInALife.ID, model.ArtworkPriorityScan), - item("mf", "no-such-mediafile", model.ArtworkPriorityScan), - )).To(Succeed()) - - purged, err := repo.PurgeDangling(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(purged).To(Equal(int64(5))) - - got, _ := repo.DequeueBatch(ctx, 100) - ids := slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) - Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID, songDayInALife.ID)) - }) - - It("enqueues entities that have no item_artwork row at all", func() { - awRepo := NewArtworkRepository(GetDBXBuilder()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: albumAbbeyRoad.ID, ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - - n, err := repo.EnqueueAllMissing(ctx, model.KindAlbumArtwork, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(BeNumerically(">=", 1)) - - got, err := repo.DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - ids := make([]string, 0, len(got)) - for _, it := range got { - Expect(it.ItemKind).To(Equal("al")) - Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) - ids = append(ids, it.ItemID) - } - Expect(ids).To(ContainElement(albumRadioactivity.ID), "an album with no row must be enqueued") - Expect(ids).ToNot(ContainElement(albumSgtPeppers.ID), "a resolved album must not be re-enqueued") - Expect(ids).ToNot(ContainElement(albumAbbeyRoad.ID), "an absent-state album must not be enqueued as missing") - }) - - It("EnqueueIfMissing skips items that already have an item_artwork row", func() { - awRepo := NewArtworkRepository(GetDBXBuilder()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "resolved", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "absent", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - - Expect(repo.EnqueueIfMissing(ctx, - item("al", "resolved", model.ArtworkPriorityScan), - item("al", "absent", model.ArtworkPriorityScan), - item("al", "brandnew", model.ArtworkPriorityScan), - )).To(Succeed()) - - got, err := repo.DequeueBatch(ctx, 100) - Expect(err).ToNot(HaveOccurred()) - ids := slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) - Expect(ids).To(ConsistOf("brandnew"), "only an item with no state row may be enqueued") - }) - - It("EnqueueIfMissing leaves an already-queued row untouched", func() { - Expect(repo.Enqueue(ctx, item("al", "queued", model.ArtworkPriorityBump))).To(Succeed()) - - Expect(repo.EnqueueIfMissing(ctx, item("al", "queued", model.ArtworkPriorityScan))).To(Succeed()) - - got, _ := repo.DequeueBatch(ctx, 100) - Expect(got).To(HaveLen(1)) - Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "the existing priority must survive") - }) - - Describe("EnqueueBySource", func() { - BeforeEach(func() { - artRepo := NewArtworkRepository(GetDBXBuilder()) - for _, ia := range []model.ItemArtwork{ - {ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "h1", Source: "external:deezer"}, - {ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "h2", Source: "external:lastfm"}, - {ItemKind: "ar", ItemID: "ar3", ImageType: model.ImageTypePrimary, Hash: "", Source: ""}, - {ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "h4", Source: "external:deezer"}, - } { - Expect(artRepo.PutItemArtwork(ctx, &ia)).To(Succeed()) - } - }) - - It("enqueues only the matching source within the kind", func() { - n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(1)), "al1 is a different kind and must not be touched") - - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar1")) - }) - - It("treats the empty source as absent", func() { - n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{""}, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(1))) - - got, _ := repo.DequeueBatch(ctx, 10) - Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar3")) - }) - - It("enqueues every source when none is given", func() { - n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, nil, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(3))) - }) - - It("leaves the current artwork state in place", func() { - _, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - - artRepo := NewArtworkRepository(GetDBXBuilder()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(Equal("h1"), "the current image must survive until it is replaced") - Expect(ia.Source).To(Equal("external:deezer")) - }) - - It("does not disturb an already-queued row", func() { - Expect(repo.Enqueue(ctx, item("ar", "ar1", model.ArtworkPriorityBump))).To(Succeed()) - - n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(BeZero()) - - got, _ := repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) - }) - - It("counts without enqueueing", func() { - n, err := repo.CountBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(1))) - - queued, err := repo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(BeZero(), "CountBySource must not enqueue") - }) - - It("counts the absent source and every source", func() { - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{""})).To(Equal(int64(1))) - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, nil)).To(Equal(int64(3))) - }) - - It("lists the distinct sources in use by a kind", func() { - Expect(repo.SourcesInUse(ctx, model.KindArtistArtwork)).To(ConsistOf("", "external:deezer", "external:lastfm")) - Expect(repo.SourcesInUse(ctx, model.KindAlbumArtwork)).To(ConsistOf("external:deezer")) - Expect(repo.SourcesInUse(ctx, model.KindRadioArtwork)).To(BeEmpty()) - }) - }) - - It("does not disturb an already-queued entity when enqueueing missing rows", func() { - Expect(repo.Enqueue(ctx, item("al", albumRadioactivity.ID, model.ArtworkPriorityBump))).To(Succeed()) - - _, err := repo.EnqueueAllMissing(ctx, model.KindAlbumArtwork, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - - got, _ := repo.DequeueBatch(ctx, 1000) - var count int - for _, it := range got { - if it.ItemID == albumRadioactivity.ID { - count++ - Expect(it.Priority).To(Equal(model.ArtworkPriorityBump), "existing bump priority must survive") - } - } - Expect(count).To(Equal(1), "the already-queued row must not be duplicated") - }) - - Describe("status counters", func() { - It("groups queue rows by kind and priority", func() { - Expect(repo.Enqueue(ctx, item("ar", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) - Expect(repo.Enqueue(ctx, item("ar", "a2", model.ArtworkPriorityBackfill))).To(Succeed()) - Expect(repo.Enqueue(ctx, item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed()) - Expect(repo.Enqueue(ctx, item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) - - Expect(repo.CountQueued(ctx, nil, nil)).To(ConsistOf( - model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, - model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1}, - model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, - )) - }) - - It("selects only the absent states that gave up, not those a source answered", func() { - awRepo := NewArtworkRepository(GetDBXBuilder()) - for _, ia := range []model.ItemArtwork{ - {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, - {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, - {ItemKind: "ar", ItemID: "hasart", ImageType: model.ImageTypePrimary, Hash: "hX", LastFailure: "[]"}, - } { - Expect(awRepo.PutItemArtwork(ctx, &ia)).To(Succeed()) - } - - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(1)), - "an item still serving art is not absent, however its last attempt went") - - // A later success rewrites the row, clearing the record. - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "gaveup", - ImageType: model.ImageTypePrimary, Hash: "hZ"})).To(Succeed()) - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) - }) - - It("unions the failed pseudo-source with a real one, so absent plus failed is just absent", func() { - awRepo := NewArtworkRepository(GetDBXBuilder()) - for _, ia := range []model.ItemArtwork{ - {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, - {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, - {ItemKind: "ar", ItemID: "folder", ImageType: model.ImageTypePrimary, Hash: "hX", Source: "folder"}, - } { - Expect(awRepo.PutItemArtwork(ctx, &ia)).To(Succeed()) - } - failedAndAbsent := []string{model.ArtworkSourceFailed, ""} - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, failedAndAbsent)).To(Equal(int64(2)), - "failed is a subset of absent, so asking for both is asking for absent") - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{model.ArtworkSourceFailed, "folder"})). - To(Equal(int64(2)), "a pseudo-source and a stored source combine as a union") - }) - - It("reports a kind with nothing failed as zero", func() { - Expect(repo.CountBySource(ctx, model.KindRadioArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) - }) - - It("reports an empty queue as no rows", func() { - Expect(repo.CountQueued(ctx, nil, nil)).To(BeEmpty()) - }) - - }) - - Describe("PurgeQueued", func() { - queuedIDs := func() []string { - GinkgoHelper() - got, err := repo.DequeueBatch(ctx, 100) - Expect(err).ToNot(HaveOccurred()) - return slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) - } - - BeforeEach(func() { - Expect(repo.Enqueue(ctx, - item("ar", "ar-backfill", model.ArtworkPriorityBackfill), - item("ar", "ar-bump", model.ArtworkPriorityBump), - item("al", "al-backfill", model.ArtworkPriorityBackfill), - item("mf", "mf-scan", model.ArtworkPriorityScan), - )).To(Succeed()) - }) - - // CountQueued feeds the preview and PurgeQueued does the delete; they share one filter, so - // every selection must count exactly what it deletes. - DescribeTable("selects the same rows to count and to delete", - func(kinds []model.Kind, priorities []int, deleted int, remaining []string) { - counted, err := repo.CountQueued(ctx, kinds, priorities) - Expect(err).ToNot(HaveOccurred()) - var total int64 - for _, s := range counted { - total += s.Count - } - Expect(total).To(BeNumerically("==", deleted), "the preview must match the delete") - - Expect(repo.PurgeQueued(ctx, kinds, priorities)).To(BeNumerically("==", deleted)) - Expect(queuedIDs()).To(ConsistOf(remaining)) - }, - Entry("only the given kinds", []model.Kind{model.KindArtistArtwork}, nil, - 2, []string{"al-backfill", "mf-scan"}), - Entry("only the given priorities", nil, []int{model.ArtworkPriorityBackfill}, - 2, []string{"ar-bump", "mf-scan"}), - Entry("the intersection of both", []model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBackfill}, - 1, []string{"ar-bump", "al-backfill", "mf-scan"}), - Entry("everything, when neither filter is given", nil, nil, - 4, []string{}), - Entry("several kinds and priorities at once", - []model.Kind{model.KindArtistArtwork, model.KindMediaFileArtwork}, - []int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan}, - 2, []string{"ar-bump", "al-backfill"}), - Entry("nothing, leaving the queue alone", []model.Kind{model.KindPlaylistArtwork}, nil, - 0, []string{"ar-backfill", "ar-bump", "al-backfill", "mf-scan"}), - ) - - It("deletes a row that is still backing off", func() { - backOff("ar", "ar-bump", time.Now().Add(time.Hour)) - - Expect(repo.PurgeQueued(ctx, []model.Kind{model.KindArtistArtwork}, nil)).To(BeNumerically("==", 2)) - Expect(repo.Get(ctx, model.KindArtistArtwork, "ar-bump", model.ImageTypePrimary)). - Error().To(MatchError(model.ErrNotFound)) - }) - - // A WHERE clause, even one that matches everything, costs SQLite its truncate optimization - // and turns `artwork cancel --all` into a full scan of the queue. - It("adds no conditions at all for an empty filter", func() { - Expect(artworkQueueFilter(nil, nil)).To(BeEmpty()) - Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, nil)).To(HaveLen(1)) - Expect(artworkQueueFilter(nil, []int{model.ArtworkPriorityBump})).To(HaveLen(1)) - Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBump})). - To(HaveLen(2)) - }) - }) -}) diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go deleted file mode 100644 index bd3a3d87c..000000000 --- a/persistence/artwork_repository.go +++ /dev/null @@ -1,184 +0,0 @@ -package persistence - -import ( - "cmp" - "context" - "slices" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" -) - -const ( - itemArtworkTable = "item_artwork" - artworkBatchSize = 200 -) - -type artworkRepository struct { - sqlRepository - items sqlRepository -} - -func NewArtworkRepository(db dbx.Builder) model.ArtworkRepository { - r := &artworkRepository{} - r.db = db - r.tableName = "artwork" - r.items.db = db - r.items.tableName = itemArtworkTable - return r -} - -func (r *artworkRepository) GetImage(ctx context.Context, hash string) (*model.Artwork, error) { - sel := Select("*").From(r.tableName).Where(Eq{"hash": hash}) - var res model.Artwork - if err := r.queryOne(ctx, sel, &res); err != nil { - return nil, err - } - return &res, nil -} - -func (r *artworkRepository) PutImage(ctx context.Context, a *model.Artwork) error { - // created_at is the last-acquisition-write time the prune grace window keys on. - a.CreatedAt = time.Now() - values, err := toSQLArgs(*a) - if err != nil { - return err - } - // created_at=excluded.created_at: reacquiring an orphan must reset the prune grace window. - ins := Insert(r.tableName).SetMap(values).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, - height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, - thumb_hash=excluded.thumb_hash, dominant_color=excluded.dominant_color, - created_at=excluded.created_at`) - _, err = r.executeSQL(ctx, ins) - return err -} - -func (r *artworkRepository) GetMimeByHash(ctx context.Context) (map[string]string, error) { - sel := Select("hash", "mime").From(r.tableName) - var rows []struct { - Hash string - Mime string - } - if err := r.queryAll(ctx, sel, &rows); err != nil { - return nil, err - } - res := make(map[string]string, len(rows)) - for _, row := range rows { - res[row.Hash] = row.Mime - } - return res, nil -} - -func (r *artworkRepository) PurgeOrphans(ctx context.Context, createdBefore time.Time) (int64, error) { - del := Delete(r.tableName).Where(And{ - Lt{"created_at": createdBefore}, - Expr("hash NOT IN (SELECT hash FROM " + itemArtworkTable + " WHERE hash <> '')"), - }) - return r.executeSQL(ctx, del) -} - -// artworkOwnerTables maps an artwork kind to the table that owns the entity. -var artworkOwnerTables = map[model.Kind]string{ - model.KindAlbumArtwork: "album", - model.KindArtistArtwork: "artist", - model.KindPlaylistArtwork: "playlist", - model.KindRadioArtwork: "radio", - model.KindMediaFileArtwork: "media_file", -} - -// purgeDangling deletes rows in r's table whose owning entity is gone, one statement per kind. -func purgeDangling(ctx context.Context, r sqlRepository) (int64, error) { - var total int64 - for kind, entityTable := range artworkOwnerTables { - del := Delete(r.tableName).Where(And{ - Eq{"item_kind": kind.Prefix()}, - Expr("item_id NOT IN (SELECT id FROM " + entityTable + ")"), - }) - c, err := r.executeSQL(ctx, del) - if err != nil { - return total, err - } - total += c - } - return total, nil -} - -func (r *artworkRepository) PurgeDanglingItems(ctx context.Context) (int64, error) { - return purgeDangling(ctx, r.items) -} - -func (r *artworkRepository) GetItemArtwork(ctx context.Context, kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { - sel := Select("*").From(itemArtworkTable). - Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}) - var res model.ItemArtwork - if err := r.items.queryOne(ctx, sel, &res); err != nil { - return nil, err - } - return &res, nil -} - -func (r *artworkRepository) PutItemArtwork(ctx context.Context, ia *model.ItemArtwork) error { - ia.ImageType = cmp.Or(ia.ImageType, model.ImageTypePrimary) - ia.UpdatedAt = time.Now() - // PutItemArtwork records the outcome of an attempt, so an unset attempted_at is now. - if ia.AttemptedAt.IsZero() { - ia.AttemptedAt = ia.UpdatedAt - } - values, err := toSQLArgs(*ia) - if err != nil { - return err - } - ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET - hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime, - trace=excluded.trace, last_failure=excluded.last_failure, - attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`) - _, err = r.items.executeSQL(ctx, ins) - return err -} - -// PutLastFailure records why an item exhausted its retry budget. It only updates an existing row: -// inserting one would write an empty hash, which the rest of the system reads as a settled absent. -func (r *artworkRepository) PutLastFailure(ctx context.Context, kind model.Kind, id, imageType, trace string) error { - upd := Update(itemArtworkTable).Set("last_failure", trace). - Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}) - _, err := r.items.executeSQL(ctx, upd) - return err -} - -func (r *artworkRepository) DeleteForItems(ctx context.Context, kind model.Kind, ids []string) error { - for chunk := range slices.Chunk(ids, artworkBatchSize) { - if err := r.items.delete(ctx, Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil { - return err - } - } - return nil -} - -func (r *artworkRepository) GetInfoForItems(ctx context.Context, kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { - res := map[string]model.ItemArtworkInfo{} - for chunk := range slices.Chunk(ids, artworkBatchSize) { - sel := Select("ia.item_id", "ia.hash", "COALESCE(a.blur_hash, '') as blur_hash", - "COALESCE(a.thumb_hash, '') as thumb_hash", - "COALESCE(a.dominant_color, '') as dominant_color", - "COALESCE(a.width, 0) as width", "COALESCE(a.height, 0) as height"). - From(itemArtworkTable + " ia"). - LeftJoin("artwork a ON a.hash = ia.hash"). - Where(And{ - Eq{"ia.item_kind": kind.Prefix()}, - Eq{"ia.image_type": model.ImageTypePrimary}, - Eq{"ia.item_id": chunk}, - }) - var rows []model.ItemArtworkInfo - if err := r.items.queryAll(ctx, sel, &rows); err != nil { - return nil, err - } - for _, row := range rows { - res[row.ItemID] = row - } - } - return res, nil -} - -var _ model.ArtworkRepository = (*artworkRepository)(nil) diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go deleted file mode 100644 index 338a7c3ab..000000000 --- a/persistence/artwork_repository_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package persistence - -import ( - "context" - "fmt" - "time" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -func clearArtworkTables() { - db := GetDBXBuilder() - for _, t := range []string{"artwork_queue", "item_artwork", "artwork"} { - _, err := db.NewQuery("DELETE FROM " + t).Execute() - Expect(err).ToNot(HaveOccurred()) - } -} - -var _ = Describe("ArtworkRepository", func() { - var ctx context.Context - var repo model.ArtworkRepository - - BeforeEach(func() { - ctx = GinkgoT().Context() - clearArtworkTables() - DeferCleanup(clearArtworkTables) - repo = NewArtworkRepository(GetDBXBuilder()) - }) - - Context("resolution traces", func() { - const traceJSON = `[{"c":"cover.*","o":"hit"}]` - - It("round-trips the trace with the state row", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t1", - ImageType: model.ImageTypePrimary, Hash: "h1", Trace: traceJSON})).To(Succeed()) - - got, err := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "t1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Trace).To(Equal(traceJSON)) - Expect(got.LastFailure).To(BeEmpty()) - }) - - It("replaces the trace when the item is resolved again", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t2", - ImageType: model.ImageTypePrimary, Trace: traceJSON})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t2", - ImageType: model.ImageTypePrimary, Trace: `[{"c":"embedded","o":"hit"}]`})).To(Succeed()) - - got, _ := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "t2", model.ImageTypePrimary) - Expect(got.Trace).To(Equal(`[{"c":"embedded","o":"hit"}]`)) - }) - - It("records a last failure on an existing row", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t3", - ImageType: model.ImageTypePrimary, Hash: "h3"})).To(Succeed()) - - Expect(repo.PutLastFailure(ctx, model.KindAlbumArtwork, "t3", model.ImageTypePrimary, - `[{"c":"decode","o":"error"}]`)).To(Succeed()) - - got, _ := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "t3", model.ImageTypePrimary) - Expect(got.LastFailure).To(Equal(`[{"c":"decode","o":"error"}]`)) - Expect(got.Hash).To(Equal("h3"), "recording a failure must not disturb the served artwork") - }) - - // Inserting here would write hash='', which every reader treats as a settled absent. - It("never creates a row for an item that has no state", func() { - Expect(repo.PutLastFailure(ctx, model.KindAlbumArtwork, "ghost", model.ImageTypePrimary, - `[{"c":"decode","o":"error"}]`)).To(Succeed()) - - _, err := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "ghost", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Context("image identity", func() { - It("stores and retrieves an artwork by hash", func() { - a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"} - Expect(repo.PutImage(ctx, a)).To(Succeed()) - - got, err := repo.GetImage(ctx, "abc123") - Expect(err).ToNot(HaveOccurred()) - Expect(got.Mime).To(Equal("image/jpeg")) - Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) - Expect(got.CreatedAt).ToNot(BeZero()) - }) - - It("round-trips the thumbhash alongside the blurhash", func() { - a := &model.Artwork{Hash: "both1", Mime: "image/jpeg", BlurHash: "LKO2?U%2Tw=w", - ThumbHash: "1QcSHQRnh493V4dIh4eXh1h4kJUI", DominantColor: "#336699"} - Expect(repo.PutImage(ctx, a)).To(Succeed()) - - got, err := repo.GetImage(ctx, "both1") - Expect(err).ToNot(HaveOccurred()) - Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) - Expect(got.ThumbHash).To(Equal("1QcSHQRnh493V4dIh4eXh1h4kJUI")) - Expect(got.DominantColor).To(Equal("#336699")) - }) - - It("overwrites the thumbhash on re-acquisition", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "first", DominantColor: "#111111"})).To(Succeed()) - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "second", DominantColor: "#222222"})).To(Succeed()) - - got, err := repo.GetImage(ctx, "th2") - Expect(err).ToNot(HaveOccurred()) - Expect(got.ThumbHash).To(Equal("second")) - Expect(got.DominantColor).To(Equal("#222222")) - }) - - It("is idempotent on Put (upsert by hash)", func() { - a := &model.Artwork{Hash: "dup1", Mime: "image/png"} - Expect(repo.PutImage(ctx, a)).To(Succeed()) - a.BlurHash = "XYZ" - Expect(repo.PutImage(ctx, a)).To(Succeed()) - got, _ := repo.GetImage(ctx, "dup1") - Expect(got.BlurHash).To(Equal("XYZ")) - }) - - It("refreshes created_at when reacquiring an existing hash", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "reacq", Mime: "image/jpeg"})).To(Succeed()) - _, err := GetDBXBuilder().NewQuery("UPDATE artwork SET created_at={:t} WHERE hash='reacq'"). - Bind(dbx.Params{"t": "2000-01-01 00:00:00"}).Execute() - Expect(err).ToNot(HaveOccurred()) - - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "reacq", Mime: "image/png"})).To(Succeed()) - - got, err := repo.GetImage(ctx, "reacq") - Expect(err).ToNot(HaveOccurred()) - Expect(got.CreatedAt).To(BeTemporally(">", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))) - }) - - It("returns ErrNotFound for a missing hash", func() { - _, err := repo.GetImage(ctx, "nope") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("returns every stored hash with its current mime", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "all1", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "all2", Mime: "image/png"})).To(Succeed()) - mimes, err := repo.GetMimeByHash(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(mimes).To(HaveKeyWithValue("all1", "image/jpeg")) - Expect(mimes).To(HaveKeyWithValue("all2", "image/png")) - }) - - It("deletes only unreferenced rows older than the cutoff, reporting the count", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "d1", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "dref", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "a1", - ImageType: model.ImageTypePrimary, Hash: "dref", Source: "folder"})).To(Succeed()) - - Expect(repo.PurgeOrphans(ctx, time.Now().Add(time.Minute))).To(BeNumerically("==", 1)) - - _, err := repo.GetImage(ctx, "d1") - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = repo.GetImage(ctx, "dref") - Expect(err).ToNot(HaveOccurred()) - }) - - It("spares an unreferenced row younger than the cutoff", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "young", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PurgeOrphans(ctx, time.Now().Add(-time.Hour))).To(BeNumerically("==", 0)) - _, err := repo.GetImage(ctx, "young") - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Context("dangling state cleanup", func() { - It("purges item_artwork rows per kind whose entity no longer exists, summing counts", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "keepAl"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "no-such-album", ImageType: model.ImageTypePrimary, Hash: "danglingAl"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: artistKraftwerk.ID, ImageType: model.ImageTypePrimary, Hash: "keepAr"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "no-such-artist", ImageType: model.ImageTypePrimary, Hash: "danglingAr"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "pl", ItemID: plsBest.ID, ImageType: model.ImageTypePrimary, Hash: "keepPl"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "pl", ItemID: "no-such-playlist", ImageType: model.ImageTypePrimary, Hash: "danglingPl"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ra", ItemID: radioWithHomePage.ID, ImageType: model.ImageTypePrimary, Hash: "keepRa"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ra", ItemID: "no-such-radio", ImageType: model.ImageTypePrimary, Hash: "danglingRa"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: songDayInALife.ID, ImageType: model.ImageTypePrimary, Hash: "keepMf"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "no-such-mediafile", ImageType: model.ImageTypePrimary, Hash: "danglingMf"})).To(Succeed()) - - purged, err := repo.PurgeDanglingItems(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(purged).To(Equal(int64(5))) - - for _, kept := range []model.ItemArtwork{ - {ItemKind: "al", ItemID: albumSgtPeppers.ID}, - {ItemKind: "ar", ItemID: artistKraftwerk.ID}, - {ItemKind: "pl", ItemID: plsBest.ID}, - {ItemKind: "ra", ItemID: radioWithHomePage.ID}, - {ItemKind: "mf", ItemID: songDayInALife.ID}, - } { - k, _ := model.ParseKind(kept.ItemKind) - _, err := repo.GetItemArtwork(ctx, k, kept.ItemID, model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - } - for _, gone := range []model.ItemArtwork{ - {ItemKind: "al", ItemID: "no-such-album"}, - {ItemKind: "ar", ItemID: "no-such-artist"}, - {ItemKind: "pl", ItemID: "no-such-playlist"}, - {ItemKind: "ra", ItemID: "no-such-radio"}, - {ItemKind: "mf", ItemID: "no-such-mediafile"}, - } { - k, _ := model.ParseKind(gone.ItemKind) - _, err := repo.GetItemArtwork(ctx, k, gone.ItemID, model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - } - }) - }) - - Context("item state", func() { - It("upserts and reads state, including per-item provenance", func() { - ia := &model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, - Hash: "h1", Source: "folder", SourcePath: "/music/a/cover.jpg", RefMtime: 111, AttemptedAt: time.Now()} - Expect(repo.PutItemArtwork(ctx, ia)).To(Succeed()) - ia.Source = "embedded" - ia.SourcePath = "/music/a/track.mp3" - ia.RefMtime = 222 - Expect(repo.PutItemArtwork(ctx, ia)).To(Succeed()) - - got, err := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Source).To(Equal("embedded")) - Expect(got.SourcePath).To(Equal("/music/a/track.mp3")) - Expect(got.RefMtime).To(Equal(int64(222))) - Expect(got.UpdatedAt).ToNot(BeZero()) - }) - - It("defaults attempted_at to now when unset", func() { - before := time.Now().Add(-time.Second) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "noattempt", - ImageType: model.ImageTypePrimary, Hash: ""})).To(Succeed()) - got, err := repo.GetItemArtwork(ctx, model.KindArtistArtwork, "noattempt", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(got.AttemptedAt).To(BeTemporally(">", before)) - }) - - It("represents known-absent as empty hash", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", - ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - got, err := repo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Hash).To(BeEmpty()) - }) - - It("hydrates a page in one batch, including blurhash, dimensions and absence", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "h9", Mime: "image/jpeg", BlurHash: "BH9", - DominantColor: "#abcdef", Width: 1200, Height: 800})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "x1", ImageType: model.ImageTypePrimary, Hash: "h9", Source: "folder"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "x2", ImageType: model.ImageTypePrimary, Hash: "", Source: ""})).To(Succeed()) - - info, err := repo.GetInfoForItems(ctx, model.KindAlbumArtwork, []string{"x1", "x2", "x3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(info).To(HaveLen(2)) - Expect(info["x1"].Hash).To(Equal("h9")) - Expect(info["x1"].BlurHash).To(Equal("BH9")) - Expect(info["x1"].DominantColor).To(Equal("#abcdef")) - Expect(info["x1"].Width).To(Equal(1200)) - Expect(info["x1"].Height).To(Equal(800)) - Expect(info["x1"].Absent()).To(BeFalse()) - Expect(info["x2"].Absent()).To(BeTrue()) - _, unresolved := info["x3"] - Expect(unresolved).To(BeFalse()) - }) - - It("deletes all rows for a single item", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "pl", ItemID: "p1", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) - Expect(repo.DeleteForItems(ctx, model.KindPlaylistArtwork, []string{"p1"})).To(Succeed()) - _, err := repo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "p1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("deletes rows for many items in chunks, leaving others untouched", func() { - const n = artworkBatchSize + 5 - ids := make([]string, n) - for i := range n { - id := fmt.Sprintf("mf-%d", i) - ids[i] = id - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: id, ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) - } - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "keep", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) - - Expect(repo.DeleteForItems(ctx, model.KindMediaFileArtwork, ids)).To(Succeed()) - - for _, id := range ids { - _, err := repo.GetItemArtwork(ctx, model.KindMediaFileArtwork, id, model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - } - kept, err := repo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "keep", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(kept.ItemID).To(Equal("keep")) - }) - }) -}) diff --git a/persistence/collation_test.go b/persistence/collation_test.go index dff91148e..bb1276577 100644 --- a/persistence/collation_test.go +++ b/persistence/collation_test.go @@ -50,6 +50,9 @@ 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 d2f817438..a1bae3170 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -3,9 +3,7 @@ package persistence import ( "errors" "fmt" - "maps" "reflect" - "slices" "strconv" "strings" "time" @@ -13,7 +11,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" - "golang.org/x/text/unicode/norm" ) type smartPlaylistJoinType int @@ -22,7 +19,6 @@ const ( smartPlaylistJoinNone smartPlaylistJoinType = 0 smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota smartPlaylistJoinArtistAnnotation - smartPlaylistJoinAlbum ) func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { @@ -30,11 +26,9 @@ func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { } type smartPlaylistField struct { - 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. + expr string + order string + joinType smartPlaylistJoinType } type smartPlaylistCriteria struct { @@ -76,7 +70,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", emptyValues: []string{"[]"}}, + "lyrics": {expr: "media_file.lyrics"}, "sorttitle": {expr: "media_file.sort_title"}, "sortalbum": {expr: "media_file.sort_album_name"}, "sortartist": {expr: "media_file.sort_artist_name"}, @@ -92,27 +86,22 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "samplerate": {expr: "media_file.sample_rate"}, "bpm": {expr: "media_file.bpm"}, "channels": {expr: "media_file.channels"}, - "loved": {expr: "annotation.starred", coalesceDefault: false}, + "loved": {expr: "COALESCE(annotation.starred, false)"}, "dateloved": {expr: "annotation.starred_at"}, "lastplayed": {expr: "annotation.play_date"}, "daterated": {expr: "annotation.rated_at"}, - "playcount": {expr: "annotation.play_count", coalesceDefault: 0}, - "rating": {expr: "annotation.rating", coalesceDefault: 0}, + "playcount": {expr: "COALESCE(annotation.play_count, 0)"}, + "rating": {expr: "COALESCE(annotation.rating, 0)"}, "averagerating": {expr: "media_file.average_rating"}, - "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}, + "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}, "albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation}, - "albumdateadded": {expr: "album.created_at", joinType: smartPlaylistJoinAlbum}, - "albumdatemodified": {expr: "album.updated_at", joinType: smartPlaylistJoinAlbum}, - "albumduration": {expr: "album.duration", joinType: smartPlaylistJoinAlbum}, - "albumsongcount": {expr: "album.song_count", joinType: smartPlaylistJoinAlbum}, - "albumsize": {expr: "album.size", joinType: smartPlaylistJoinAlbum}, - "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}, + "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}, "artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation}, "artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation}, "artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation}, @@ -130,7 +119,7 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "random": {order: "random()"}, } -func (c smartPlaylistCriteria) where() (squirrel.Sqlizer, error) { +func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { if c.Criteria.Expression == nil { return squirrel.Expr("1 = 1"), nil } @@ -148,7 +137,7 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } and = append(and, cond) } - return mergeNegatedJsonConds(and), nil + return and, nil case criteria.Any: or := squirrel.Or{} for _, child := range e { @@ -158,19 +147,29 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } or = append(or, cond) } - return mergeJsonConds(or), nil + return or, nil case criteria.Is: - return comparisonExpr(e, cmpEq) + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Eq(fields) + }, false) case criteria.IsNot: return isNotExpr(e) case criteria.Gt: - return comparisonExpr(e, cmpGt) + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Gt(fields) + }, false) case criteria.Lt: - return comparisonExpr(e, cmpLt) + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Lt(fields) + }, false) case criteria.Before: - return comparisonExpr(e, cmpLt) + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Lt(fields) + }, false) case criteria.After: - return comparisonExpr(e, cmpGt) + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Gt(fields) + }, false) case criteria.Contains: return likeExpr(e, "%%%v%%", false) case criteria.NotContains: @@ -202,7 +201,11 @@ 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 } - return comparisonExpr(values, cmpNe) + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return squirrel.NotEq(fields), nil } func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) { @@ -213,57 +216,32 @@ 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 +} - 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) +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 } + 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 { - 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 - } + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil } fields, err := sqlFields(values) if err != nil { @@ -283,36 +261,23 @@ 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) { - 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) + fields, err := sqlFields(values) if err != nil { return nil, err } - high, err := comparisonExpr(map[string]any{field: s.Index(1).Interface()}, cmpLe) - 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()}, + ) } - return squirrel.And{low, high}, nil + return and, nil } func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) { @@ -345,15 +310,11 @@ func startOfPeriod(numDays int64, from time.Time) string { } func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) { - var condition squirrel.Sqlizer - if playlistId, ok := values["id"].(string); ok && playlistId != "" { - condition = squirrel.Eq{"pl.playlist_id": playlistId} - } else if playlistPath, ok := values["path"].(string); ok && playlistPath != "" { - condition = squirrel.Eq{"playlist.path": pathVariants(playlistPath)} - } else { - return nil, errors.New("playlist id or path not given") + playlistID, ok := values["id"].(string) + if !ok { + return nil, errors.New("playlist id not given") } - filters := squirrel.And{condition} + filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} if !c.owner.IsAdmin { if c.owner.ID == "" { filters = append(filters, squirrel.Eq{"playlist.public": 1}) @@ -378,18 +339,6 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil } -// Filesystems disagree on the Unicode form of a name, so match the path in NFC and NFD. -func pathVariants(path string) []string { - variants := []string{path} - if alt := norm.NFC.String(path); alt != path { - variants = append(variants, alt) - } - if alt := norm.NFD.String(path); alt != path { - variants = append(variants, alt) - } - return variants -} - func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { if info.IsRole { return roleCond{role: info.Name(), cond: cond, not: negate} @@ -432,195 +381,17 @@ type roleCond struct { func (e roleCond) ToSql() (string, []any, error) { var cond string var args []any + var err error if e.cond != nil { - innerSQL, innerArgs, err := roleCondSQL(e.cond) - if err != nil { - return "", nil, err - } - cond = roleExistsSQL(innerSQL) - args = append([]any{e.role}, innerArgs...) + 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) } else { - cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)" - args = []any{e.role} + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role) } if e.not { cond = "not " + cond } - 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 + return cond, args, err } func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) { @@ -658,139 +429,6 @@ 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 { @@ -803,7 +441,7 @@ func fieldJoinType(name string) smartPlaylistJoinType { return field.joinType } -func (c smartPlaylistCriteria) expressionJoins() smartPlaylistJoinType { +func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { var joins smartPlaylistJoinType _ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error { for field := range criteria.Fields(expr) { @@ -814,50 +452,15 @@ func (c smartPlaylistCriteria) expressionJoins() smartPlaylistJoinType { return joins } -func (c smartPlaylistCriteria) requiredJoins() smartPlaylistJoinType { - joins := c.expressionJoins() +func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType { + joins := c.ExpressionJoins() for _, name := range c.Criteria.SortFieldNames() { joins |= fieldJoinType(name) } return joins } -// applyExpressionJoins adds every join the criteria's WHERE clause resolves against. -func (c smartPlaylistCriteria) applyExpressionJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder { - return c.applyJoins(sq, c.expressionJoins(), userID) -} - -// applyRequiredJoins adds the WHERE joins plus any the ORDER BY resolves against. -func (c smartPlaylistCriteria) applyRequiredJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder { - return c.applyJoins(sq, c.requiredJoins(), userID) -} - -// applyJoins joins the media_file annotation unconditionally — annotation fields -// COALESCE a missing row to a default, so the row has to be reachable to be absent. -func (c smartPlaylistCriteria) applyJoins(sq squirrel.SelectBuilder, joins smartPlaylistJoinType, userID string) squirrel.SelectBuilder { - sq = sq.LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", userID) - if joins.has(smartPlaylistJoinAlbumAnnotation) { - sq = sq.LeftJoin("annotation AS album_annotation ON ("+ - "album_annotation.item_id = media_file.album_id"+ - " AND album_annotation.item_type = 'album'"+ - " AND album_annotation.user_id = ?)", userID) - } - if joins.has(smartPlaylistJoinArtistAnnotation) { - sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ - "artist_annotation.item_id = media_file.artist_id"+ - " AND artist_annotation.item_type = 'artist'"+ - " AND artist_annotation.user_id = ?)", userID) - } - if joins.has(smartPlaylistJoinAlbum) { - sq = sq.LeftJoin("album ON album.id = media_file.album_id") - } - return sq -} - -func (c smartPlaylistCriteria) orderBy() string { +func (c smartPlaylistCriteria) OrderBy() string { sortFields := c.Criteria.OrderByFields() parts := make([]string, 0, len(sortFields)) for _, sf := range sortFields { @@ -893,9 +496,7 @@ func sortExpr(sortField string) (string, bool) { if !ok || field.expr == "" { return "", false } - // 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() + mapped = field.expr } 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 deleted file mode 100644 index 30c1ba100..000000000 --- a/persistence/criteria_sql_benchmark_test.go +++ /dev/null @@ -1,291 +0,0 @@ -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(conn) - if err := ur.Put(ctx, &user); err != nil { - b.Fatal(err) - } - if err := ur.SetUserLibraries(ctx, 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 ef91ec989..ae2695a4d 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -1,8 +1,6 @@ package persistence import ( - "fmt" - "strings" "time" "github.com/navidrome/navidrome/model" @@ -14,13 +12,13 @@ import ( var _ = Describe("Smart playlist criteria SQL", func() { BeforeEach(func() { criteria.AddRoles([]string{"artist", "composer", "producer"}) - criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate", "replaygain_album_gain"}) + criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"}) criteria.AddNumericTags([]string{"rate"}) }) DescribeTable("expressions", func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) { - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -30,16 +28,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 annotation.rating > ?)", "%love%", 3), + "(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%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}, "annotation.starred = ?", true), + Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", 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}, "annotation.play_count > ?", 10), - Entry("lt", criteria.Lt{"playCount": 10}, "(annotation.play_count < ? OR annotation.play_count IS NULL)", 10), + 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("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,61 +45,10 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), Entry("before", criteria.Before{"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("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 [path]", criteria.InPlaylist{"path": "lacuslacus.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "lacuslacus.nsp", 1), - Entry("in playlist [id]", 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("in playlist [empty id falls back to path]", criteria.InPlaylist{"id": "", "path": "/music/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "/music/x.nsp", 1), - Entry("in playlist [decomposed unicode path]", criteria.InPlaylist{"path": "/m\u00fasica/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?,?) AND playlist.public = ?))", "/m\u00fasica/x.nsp", "/mu\u0301sica/x.nsp", 1), + 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}, "album_annotation.rating > ?", 3), - Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true), - Entry("album column", criteria.Gt{"albumSongCount": 5}, "album.song_count > ?", 5), - Entry("album duration column", criteria.Lt{"albumDuration": 600}, "album.duration < ?", 600), - Entry("album size column", criteria.Gt{"albumSize": 1000}, "album.size > ?", 1000), - Entry("album date column", criteria.After{"albumDateAdded": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.created_at > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), - Entry("album modified column", criteria.Before{"albumDateModified": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.updated_at < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), - // 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("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("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%"), @@ -109,9 +56,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 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%"), + 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%"), // 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), @@ -123,9 +70,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 media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), + "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, - "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), + "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), // isPresent — tags Entry("isPresent tag [true]", criteria.IsPresent{"genre": true}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), @@ -133,73 +80,9 @@ 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 media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), + "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, - "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 <> ?)", ""), + "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), ) Describe("playlist permissions", func() { @@ -207,7 +90,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}), - ).where() + ).Where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -220,7 +103,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}), - ).where() + ).Where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -231,7 +114,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { }) It("builds relative date expressions", func() { - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -241,7 +124,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { }) It("builds negated relative date expressions", func() { - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).where() + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where() Expect(err).ToNot(HaveOccurred()) sql, args, err := sqlizer.ToSql() @@ -251,83 +134,61 @@ var _ = Describe("Smart playlist criteria SQL", func() { }) It("returns an error for unknown fields", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where() Expect(err).To(MatchError("invalid field in criteria: unknown")) }) 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 not supported for field"))) + _, 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"))) }) 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 not supported for field"))) + _, 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"))) }) It("returns an error when isMissing has a non-boolean value", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).where() + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where() Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) }) - It("returns an error when inPlaylist has empty path", func() { - _, err := newSmartPlaylistCriteria( - criteria.Criteria{Expression: criteria.InPlaylist{"path": ""}}, - withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false})).where() - Expect(err).To(MatchError(ContainSubstring("playlist id or path not given"))) - }) - - 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")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) }) It("sorts by tag fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc")) }) It("sorts by role fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc")) }) It("casts numeric tags when sorting", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).orderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc")) }) It("sorts by albumtype alias", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).orderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc")) }) It("sorts by random", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).orderBy()).To(Equal("random() asc")) - }) - - It("sorts by album columns bare, with no COALESCE default", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).orderBy()). - To(Equal("album.created_at desc, media_file.track_number asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc")) }) It("sorts by multiple fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).orderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) }) It("reverts order when order is desc", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).orderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc")) }) It("ignores invalid sort fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).orderBy()).To(Equal("media_file.title asc")) + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc")) }) }) @@ -343,272 +204,19 @@ var _ = Describe("Smart playlist criteria SQL", func() { } }) - It("declares a joinType matching the table each field selects from", func() { - // Omitting the joinType still compiles, so without this the field would only fail at - // refresh time with "no such column". - joinByTable := map[string]smartPlaylistJoinType{ - "media_file": smartPlaylistJoinNone, - "annotation": smartPlaylistJoinNone, - "album": smartPlaylistJoinAlbum, - "album_annotation": smartPlaylistJoinAlbumAnnotation, - "artist_annotation": smartPlaylistJoinArtistAnnotation, - } - for name, field := range smartPlaylistFields { - if field.expr == "" { - continue - } - table, _, ok := strings.Cut(field.expr, ".") - Expect(ok).To(BeTrue(), "field %q has expr %q with no table prefix", name, field.expr) - want, known := joinByTable[table] - Expect(known).To(BeTrue(), "field %q selects from unknown table %q", name, table) - Expect(field.joinType).To(Equal(want), "field %q selects from %q but declares the wrong joinType", name, table) - } - }) - - 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"} cSQL := newSmartPlaylistCriteria(c) - Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone)) - Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone)) + Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) }) It("includes expression-based joins", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} - Expect(newSmartPlaylistCriteria(c).expressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) }) It("detects nested album and artist joins", func() { @@ -617,7 +225,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { criteria.Any{criteria.Gt{"artistPlayCount": 10}}, }} - joins := newSmartPlaylistCriteria(c).requiredJoins() + joins := newSmartPlaylistCriteria(c).RequiredJoins() Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) }) @@ -625,23 +233,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("detects join types from sort fields with direction prefixes", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"} - Expect(newSmartPlaylistCriteria(c).requiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) - }) - - It("keeps a sort-only album join out of the expression joins", func() { - c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-albumDateAdded"} - cSQL := newSmartPlaylistCriteria(c) - - Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone)) - Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue()) - }) - - It("distinguishes the album join from the album annotation join", func() { - c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} - joins := newSmartPlaylistCriteria(c).requiredJoins() - - Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) - Expect(joins.has(smartPlaylistJoinAlbum)).To(BeFalse()) + Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) }) }) }) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index a757901d3..f42292f02 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -13,6 +13,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -69,13 +70,11 @@ var ( func buildTestFS() { abbeyRoad := template(_t{ - "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", + "albumartist": "The Beatles", + "artist": "The Beatles", + "album": "Abbey Road", + "year": 1969, + "genre": "Rock;Blues", }) ledZepIV := template(_t{ "albumartist": "Led Zeppelin", @@ -117,16 +116,12 @@ 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", - "replaygain_track_gain": "-7.1 dB", "replaygain_track_peak": "0.95"})), + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})), "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", - _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 + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})), "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, - "replaygain_track_gain": "-8.25 dB", "replaygain_track_peak": "0.99"})), + "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), "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})), @@ -143,7 +138,7 @@ func buildTestFS() { } func findMediaFileByTitle(title string) string { - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"media_file.title": title}, }) Expect(err).ToNot(HaveOccurred()) @@ -178,10 +173,10 @@ func evaluateRuleOrderedAs(owner model.User, jsonRule string) []string { OwnerID: owner.ID, Rules: &rules, } - err = ds.Playlist().Put(userCtx, pls) + err = ds.Playlist(userCtx).Put(pls) Expect(err).ToNot(HaveOccurred()) - loaded, err := ds.Playlist().GetWithTracks(userCtx, pls.ID, true, false) + loaded, err := ds.Playlist(userCtx).GetWithTracks(pls.ID, true, false) Expect(err).ToNot(HaveOccurred()) titles := make([]string, len(loaded.Tracks)) @@ -201,7 +196,7 @@ func createPlaylist(owner model.User, public bool, titles ...string) string { mfID := findMediaFileByTitle(title) pls.AddMediaFilesByID([]string{mfID}) } - Expect(ds.Playlist().Put(ctx, pls)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) return pls.ID } @@ -230,7 +225,7 @@ func createSmartPlaylist(owner model.User, public bool, jsonRule string) string Public: public, Rules: &rules, } - Expect(ds.Playlist().Put(ctx, pls)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) return pls.ID } @@ -252,44 +247,44 @@ var _ = BeforeSuite(func() { userWithPass := adminUser userWithPass.NewPassword = "password" - Expect(initDS.User().Put(ctx, &userWithPass)).To(Succeed()) + Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed()) regularUserWithPass := regularUser regularUserWithPass.NewPassword = "password" - Expect(initDS.User().Put(ctx, ®ularUserWithPass)).To(Succeed()) + Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} - Expect(initDS.Library().Put(ctx, &lib)).To(Succeed()) - Expect(initDS.User().SetUserLibraries(ctx, adminUser.ID, []int{lib.ID})).To(Succeed()) - Expect(initDS.User().SetUserLibraries(ctx, regularUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) - loadedUser, err := initDS.User().FindByUsername(ctx, adminUser.UserName) + loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) Expect(err).ToNot(HaveOccurred()) adminUser.Libraries = loadedUser.Libraries - loadedOther, err := initDS.User().FindByUsername(ctx, regularUser.UserName) + loadedOther, err := initDS.User(ctx).FindByUsername(regularUser.UserName) Expect(err).ToNot(HaveOccurred()) regularUser.Libraries = loadedOther.Libraries ctx = request.WithUser(GinkgoT().Context(), adminUser) buildTestFS() - s := scanner.New(ctx, initDS, events.NoopBroker(), - playlists.NewPlaylists(initDS, artwork.NewUploader(initDS)), metrics.NewNoopInstance()) + s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} comeTogetherID := findMediaFileByTitle("Come Together") - Expect(ds.MediaFile().SetStar(ctx, true, comeTogetherID)).To(Succeed()) - Expect(ds.MediaFile().SetStar(ctx, true, findMediaFileByTitle("So What"))).To(Succeed()) - Expect(ds.MediaFile().SetRating(ctx, 3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed()) - Expect(ds.MediaFile().SetRating(ctx, 5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed()) + Expect(ds.MediaFile(ctx).SetStar(true, comeTogetherID)).To(Succeed()) + Expect(ds.MediaFile(ctx).SetStar(true, findMediaFileByTitle("So What"))).To(Succeed()) + Expect(ds.MediaFile(ctx).SetRating(3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed()) + Expect(ds.MediaFile(ctx).SetRating(5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed()) for range 10 { - Expect(ds.MediaFile().IncPlayCount(ctx, comeTogetherID, time.Now())).To(Succeed()) + Expect(ds.MediaFile(ctx).IncPlayCount(comeTogetherID, time.Now())).To(Succeed()) } - Expect(ds.MediaFile().IncPlayCount(ctx, findMediaFileByTitle("Black Dog"), time.Now())).To(Succeed()) + Expect(ds.MediaFile(ctx).IncPlayCount(findMediaFileByTitle("Black Dog"), time.Now())).To(Succeed()) rows, err := db.Db().Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index 51442c822..a844dc982 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -204,43 +204,6 @@ var _ = Describe("Smart Playlists", func() { }) }) - Describe("Album aggregate fields", func() { - // Abbey Road and IV have two tracks each; the other four albums have one. - It("matches albums with more than one track", func() { - results := evaluateRule(`{"all":[{"gt":{"albumsongcount":1}}]}`) - Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog")) - }) - - It("matches single-track albums", func() { - results := evaluateRule(`{"all":[{"is":{"albumsongcount":1}}]}`) - Expect(results).To(ConsistOf("So What", "Bohemian Rhapsody", "All Along the Watchtower", - "We Are the Champions")) - }) - - It("matches albumDateAdded inTheLast 1 day", func() { - results := evaluateRule(`{"all":[{"inTheLast":{"albumdateadded":1}}]}`) - Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", - "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) - }) - - It("matches albumDateModified before a far-future date", func() { - results := evaluateRule(`{"all":[{"before":{"albumdatemodified":"2099-01-01"}}]}`) - Expect(results).To(HaveLen(8)) - }) - - // Fixture durations are randomized, so only the aggregate being populated can be asserted. - It("resolves albumDuration and albumSize to non-zero aggregates", func() { - Expect(evaluateRule(`{"all":[{"gt":{"albumduration":0}}]}`)).To(HaveLen(8)) - Expect(evaluateRule(`{"all":[{"gt":{"albumsize":0}}]}`)).To(HaveLen(8)) - }) - - It("groups by album date and orders within the album (issue #5347)", func() { - results := evaluateRuleOrdered( - `{"all":[{"is":{"album":"Abbey Road"}}],"sort":"-albumdateadded,tracknumber"}`) - Expect(results).To(Equal([]string{"Come Together", "Something"})) - }) - }) - Describe("Logic operators", func() { It("matches with ALL (AND)", func() { results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}},{"gt":{"bpm":130}}]}`) @@ -317,12 +280,12 @@ var _ = Describe("Smart Playlists", func() { smartBID := createPrivateSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) smartAID := createPublicSmartPlaylist(regularUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) - loadedA, err := ds.Playlist().GetWithTracks(ctx, smartAID, true, false) + loadedA, err := ds.Playlist(ctx).GetWithTracks(smartAID, true, false) Expect(err).ToNot(HaveOccurred()) Expect(loadedA.Tracks).To(BeEmpty()) Expect(loadedA.EvaluatedAt).To(BeNil()) - loadedB, err := ds.Playlist().Get(ctx, smartBID) + loadedB, err := ds.Playlist(ctx).Get(smartBID) Expect(err).ToNot(HaveOccurred()) Expect(loadedB.EvaluatedAt).To(BeNil()) }) @@ -408,61 +371,4 @@ 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 b4f7c9069..f7bb6a4fe 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -6,7 +6,8 @@ import ( "fmt" "iter" "maps" - "path" + "os" + "path/filepath" "slices" "strings" "time" @@ -27,14 +28,6 @@ type dbFolder struct { ImageFiles string `structs:"-" json:"-"` } -// String guards the promoted Folder.String(), which would dereference a nil Folder. -func (f dbFolder) String() string { - if f.Folder == nil { - return "" - } - return f.Folder.String() -} - func (f *dbFolder) PostScan() error { var err error if f.ImageFiles != "" { @@ -64,54 +57,55 @@ func (fs dbFolders) toModels() []model.Folder { return slice.Map(fs, func(f dbFolder) model.Folder { return *f.Folder }) } -func newFolderRepository(db dbx.Builder) model.FolderRepository { +func newFolderRepository(ctx context.Context, db dbx.Builder) model.FolderRepository { r := &folderRepository{} + r.ctx = ctx r.db = db r.tableName = "folder" return r } -func (r folderRepository) selectFolder(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(ctx, options...).Columns("folder.*", "library.path as library_path"). +func (r folderRepository) selectFolder(options ...model.QueryOptions) SelectBuilder { + sql := r.newSelect(options...).Columns("folder.*", "library.path as library_path"). Join("library on library.id = folder.library_id") - return r.applyLibraryFilter(ctx, sql) + return r.applyLibraryFilter(sql) } -func (r folderRepository) Get(ctx context.Context, id string) (*model.Folder, error) { - sq := r.selectFolder(ctx).Where(Eq{"folder.id": id}) +func (r folderRepository) Get(id string) (*model.Folder, error) { + sq := r.selectFolder().Where(Eq{"folder.id": id}) var res dbFolder - err := r.queryOne(ctx, sq, &res) + err := r.queryOne(sq, &res) return res.Folder, err } -func (r folderRepository) GetByPath(ctx context.Context, lib model.Library, path string) (*model.Folder, error) { +func (r folderRepository) GetByPath(lib model.Library, path string) (*model.Folder, error) { id := model.NewFolder(lib, path).ID - return r.Get(ctx, id) + return r.Get(id) } -func (r folderRepository) GetAll(ctx context.Context, opt ...model.QueryOptions) ([]model.Folder, error) { - sq := r.selectFolder(ctx, opt...) +func (r folderRepository) GetAll(opt ...model.QueryOptions) ([]model.Folder, error) { + sq := r.selectFolder(opt...) var res dbFolders - err := r.queryAll(ctx, sq, &res) + err := r.queryAll(sq, &res) return res.toModels(), err } -func (r folderRepository) CountAll(ctx context.Context, opt ...model.QueryOptions) (int64, error) { - query := r.newSelect(ctx, opt...).Columns("count(*)") - query = r.applyLibraryFilter(ctx, query) - return r.count(ctx, query) +func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) { + query := r.newSelect(opt...).Columns("count(*)") + query = r.applyLibraryFilter(query) + return r.count(query) } -func (r folderRepository) GetFolderUpdateInfo(ctx context.Context, lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) { +func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) { // If no specific paths, return all folders in the library if len(targetPaths) == 0 { - return r.getFolderUpdateInfoAll(ctx, lib) + return r.getFolderUpdateInfoAll(lib) } // Check if any path is root (return all folders) for _, targetPath := range targetPaths { if targetPath == "" || targetPath == "." { - return r.getFolderUpdateInfoAll(ctx, lib) + return r.getFolderUpdateInfoAll(lib) } } @@ -121,7 +115,7 @@ func (r folderRepository) GetFolderUpdateInfo(ctx context.Context, lib model.Lib result := make(map[string]model.FolderUpdateInfo) for batch := range slices.Chunk(targetPaths, batchSize) { - batchResult, err := r.getFolderUpdateInfoBatch(ctx, lib, batch) + batchResult, err := r.getFolderUpdateInfoBatch(lib, batch) if err != nil { return nil, err } @@ -132,16 +126,16 @@ func (r folderRepository) GetFolderUpdateInfo(ctx context.Context, lib model.Lib } // getFolderUpdateInfoAll returns update info for all non-missing folders in the library -func (r folderRepository) getFolderUpdateInfoAll(ctx context.Context, lib model.Library) (map[string]model.FolderUpdateInfo, error) { +func (r folderRepository) getFolderUpdateInfoAll(lib model.Library) (map[string]model.FolderUpdateInfo, error) { where := And{ Eq{"library_id": lib.ID}, Eq{"missing": false}, } - return r.queryFolderUpdateInfo(ctx, where) + return r.queryFolderUpdateInfo(where) } // getFolderUpdateInfoBatch returns update info for a batch of target paths and their descendants -func (r folderRepository) getFolderUpdateInfoBatch(ctx context.Context, lib model.Library, targetPaths []string) (map[string]model.FolderUpdateInfo, error) { +func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPaths []string) (map[string]model.FolderUpdateInfo, error) { where := And{ Eq{"library_id": lib.ID}, Eq{"missing": false}, @@ -152,8 +146,9 @@ func (r folderRepository) getFolderUpdateInfoBatch(ctx context.Context, lib mode pathConditions := make(Or, 0, len(targetPaths)*2) for _, targetPath := range targetPaths { - // Slash-form like the stored paths; filepath.Clean would backslash them on Windows. - cleanPath := path.Clean(strings.TrimPrefix(targetPath, "/")) + // Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes. + cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator)) + cleanPath = filepath.Clean(cleanPath) // Include the target folder itself by ID folderIDs = append(folderIDs, model.FolderID(lib, cleanPath)) @@ -171,101 +166,42 @@ func (r folderRepository) getFolderUpdateInfoBatch(ctx context.Context, lib mode where = append(where, pathConditions) } - return r.queryFolderUpdateInfo(ctx, where) + return r.queryFolderUpdateInfo(where) } // queryFolderUpdateInfo executes the query and returns the result map -func (r folderRepository) queryFolderUpdateInfo(ctx context.Context, where And) (map[string]model.FolderUpdateInfo, error) { - sq := r.newSelect(ctx).Columns("id", "updated_at", "hash", "image_files", "images_updated_at").Where(where) +func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) { + sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where) var res []struct { - ID string - UpdatedAt time.Time - Hash string - ImageFiles string - ImagesUpdatedAt time.Time + ID string + UpdatedAt time.Time + Hash string } - err := r.queryAll(ctx, sq, &res) + err := r.queryAll(sq, &res) if err != nil { return nil, err } m := make(map[string]model.FolderUpdateInfo, len(res)) for _, f := range res { - info := model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash, ImagesUpdatedAt: f.ImagesUpdatedAt} - if f.ImageFiles != "" { - if err := json.Unmarshal([]byte(f.ImageFiles), &info.ImageFiles); err != nil { - return nil, fmt.Errorf("parsing folder image_files: %w", err) - } - } - m[f.ID] = info + m[f.ID] = model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash} } return m, nil } -// subtreePathChunkSize bounds how many paths one folderSubtreeFilter may expand into: each adds -// 3 OR terms, and SQLite rejects an expression tree deeper than 1000 (measured: 166 paths). -const subtreePathChunkSize = 100 - -// folderSubtreeFilter matches the folders at the given library-relative paths and all their -// descendants. A path of "" or "." selects the whole library, so it drops the path conditions. -func folderSubtreeFilter(lib model.Library, paths []string) Sqlizer { - conds := make(Or, 0, len(paths)*3) - for _, p := range paths { - // Paths are io/fs slash-form; filepath.Clean would backslash them on Windows. - cleanPath := path.Clean(strings.TrimPrefix(p, "/")) - if cleanPath == "." { - return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}} - } - conds = append(conds, - Eq{"folder.id": model.FolderID(lib, cleanPath)}, - // Direct children have path = cleanPath; deeper descendants match the prefix - Eq{"folder.path": cleanPath}, - Expr(`folder.path LIKE ? ESCAPE '\'`, escapeLikePrefix(cleanPath)+"/%"), - ) - } - return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}, conds} -} - -// 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(ctx context.Context, 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(ctx, 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(ctx context.Context, f *model.Folder) error { +func (r folderRepository) Put(f *model.Folder) error { dbf := dbFolder{Folder: f} - _, err := r.put(ctx, dbf.ID, &dbf) + _, err := r.put(dbf.ID, &dbf) return err } -func (r folderRepository) MarkMissing(ctx context.Context, missing bool, ids ...string) error { - log.Debug(ctx, "Marking folders as missing", "ids", ids, "missing", missing) +func (r folderRepository) MarkMissing(missing bool, ids ...string) error { + log.Debug(r.ctx, "Marking folders as missing", "ids", ids, "missing", missing) for chunk := range slices.Chunk(ids, 200) { sq := Update(r.tableName). Set("missing", missing). Set("updated_at", time.Now()). Where(Eq{"id": chunk}) - _, err := r.executeSQL(ctx, sq) + _, err := r.executeSQL(sq) if err != nil { return err } @@ -273,25 +209,13 @@ func (r folderRepository) MarkMissing(ctx context.Context, missing bool, ids ... return nil } -func (r folderRepository) GetTouchedWithPlaylists(ctx context.Context) (model.FolderCursor, error) { - query := r.selectFolder(ctx).Where(And{ +func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { + query := r.selectFolder().Where(And{ Eq{"missing": false}, Gt{"num_playlists": 0}, ConcatExpr("folder.updated_at > library.last_scan_at"), }) - cursor, err := queryWithStableResults[dbFolder](ctx, r.sqlRepository, query) - if err != nil { - return nil, err - } - return wrapFolderCursor(cursor), nil -} - -func (r folderRepository) GetAllWithPlaylists(ctx context.Context) (model.FolderCursor, error) { - query := r.selectFolder(ctx).Where(And{ - Eq{"missing": false}, - Gt{"num_playlists": 0}, - }) - cursor, err := queryWithStableResults[dbFolder](ctx, r.sqlRepository, query) + cursor, err := queryWithStableResults[dbFolder](r.sqlRepository, query) if err != nil { return nil, err } @@ -299,10 +223,20 @@ func (r folderRepository) GetAllWithPlaylists(ctx context.Context) (model.Folder } func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { - return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder })) + return func(yield func(model.Folder, error) bool) { + for f, err := range cursor { + if f.Folder == nil { + yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err)) + return + } + if !yield(*f.Folder, err) || err != nil { + return + } + } + } } -func (r folderRepository) purgeEmpty(ctx context.Context, libraryIDs ...int) error { +func (r folderRepository) purgeEmpty(libraryIDs ...int) error { sq := Delete(r.tableName).Where(And{ Eq{"num_audio_files": 0}, Eq{"num_playlists": 0}, @@ -314,12 +248,12 @@ func (r folderRepository) purgeEmpty(ctx context.Context, libraryIDs ...int) err if len(libraryIDs) > 0 { sq = sq.Where(Eq{"library_id": libraryIDs}) } - c, err := r.executeSQL(ctx, sq) + c, err := r.executeSQL(sq) if err != nil { return fmt.Errorf("purging empty folders: %w", err) } if c > 0 { - log.Debug(ctx, "Purging empty folders", "totalDeleted", c) + log.Debug(r.ctx, "Purging empty folders", "totalDeleted", c) } return nil } diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index 4b1c54a3d..ebc08fd04 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -8,7 +8,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -23,17 +23,17 @@ var _ = Describe("FolderRepository", func() { BeforeEach(func() { ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) conn = GetDBXBuilder() - repo = newFolderRepository(conn) + repo = newFolderRepository(ctx, conn) // Use existing library ID 1 from test fixtures - libRepo := NewLibraryRepository(conn) - lib, err := libRepo.Get(ctx, 1) + libRepo := NewLibraryRepository(ctx, conn) + lib, err := libRepo.Get(1) Expect(err).ToNot(HaveOccurred()) testLib = *lib // Create a second library with its own folder to verify isolation otherLib = model.Library{Name: "Other Library", Path: "/other/path"} - Expect(libRepo.Put(ctx, &otherLib)).To(Succeed()) + Expect(libRepo.Put(&otherLib)).To(Succeed()) }) AfterEach(func() { @@ -43,42 +43,6 @@ var _ = Describe("FolderRepository", func() { _, _ = conn.NewQuery(fmt.Sprintf("DELETE FROM library WHERE id = %d", otherLib.ID)).Execute() }) - Describe("folderSubtreeFilter", func() { - var parent, child, grandchild, other *model.Folder - - matching := func(paths ...string) []string { - GinkgoHelper() - folders, err := repo.GetAll(ctx, model.QueryOptions{Filters: folderSubtreeFilter(testLib, paths)}) - Expect(err).ToNot(HaveOccurred()) - return slice.Map(folders, func(f model.Folder) string { return f.ID }) - } - - BeforeEach(func() { - parent = model.NewFolder(testLib, "TestSubtree") - child = model.NewFolder(testLib, "TestSubtree/Child") - grandchild = model.NewFolder(testLib, "TestSubtree/Child/Grandchild") - other = model.NewFolder(testLib, "TestSubtreeOther") - for _, f := range []*model.Folder{parent, child, grandchild, other} { - Expect(repo.Put(ctx, f)).To(Succeed()) - } - DeferCleanup(func() { - _, _ = conn.NewQuery("DELETE FROM folder WHERE name LIKE 'TestSubtree%' OR path LIKE 'TestSubtree%'").Execute() - }) - }) - - It("matches a folder and all its descendants", func() { - Expect(matching("TestSubtree")).To(ConsistOf(parent.ID, child.ID, grandchild.ID)) - }) - - It("matches the descendants of a nested slash-form path", func() { - Expect(matching("TestSubtree/Child")).To(ConsistOf(child.ID, grandchild.ID)) - }) - - It("matches the whole library for the root path", func() { - Expect(matching(".")).To(ContainElements(parent.ID, child.ID, grandchild.ID, other.ID)) - }) - }) - Describe("GetFolderUpdateInfo", func() { Context("with no target paths", func() { It("returns all folders in the library", func() { @@ -86,17 +50,17 @@ var _ = Describe("FolderRepository", func() { folder1 := model.NewFolder(testLib, "TestGetLastUpdates/Folder1") folder2 := model.NewFolder(testLib, "TestGetLastUpdates/Folder2") - err := repo.Put(ctx, folder1) + err := repo.Put(folder1) Expect(err).ToNot(HaveOccurred()) - err = repo.Put(ctx, folder2) + err = repo.Put(folder2) Expect(err).ToNot(HaveOccurred()) otherFolder := model.NewFolder(otherLib, "TestOtherLib/Folder") - err = repo.Put(ctx, otherFolder) + err = repo.Put(otherFolder) Expect(err).ToNot(HaveOccurred()) // Query all folders (no target paths) - should only return folders from testLib - results, err := repo.GetFolderUpdateInfo(ctx, testLib) + results, err := repo.GetFolderUpdateInfo(testLib) Expect(err).ToNot(HaveOccurred()) // Should include folders from testLib Expect(results).To(HaveKey(folder1.ID)) @@ -113,15 +77,15 @@ var _ = Describe("FolderRepository", func() { folder2 := model.NewFolder(testLib, "TestSpecific/Jazz") folder3 := model.NewFolder(testLib, "TestSpecific/Classical") - err := repo.Put(ctx, folder1) + err := repo.Put(folder1) Expect(err).ToNot(HaveOccurred()) - err = repo.Put(ctx, folder2) + err = repo.Put(folder2) Expect(err).ToNot(HaveOccurred()) - err = repo.Put(ctx, folder3) + err = repo.Put(folder3) Expect(err).ToNot(HaveOccurred()) // Query specific paths - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestSpecific/Rock", "TestSpecific/Classical") + results, err := repo.GetFolderUpdateInfo(testLib, "TestSpecific/Rock", "TestSpecific/Classical") Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) @@ -136,18 +100,19 @@ var _ = Describe("FolderRepository", func() { }) It("includes all child folders when querying parent", func() { + tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create a parent folder with multiple children parent := model.NewFolder(testLib, "TestParent/Music") child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen") child2 := model.NewFolder(testLib, "TestParent/Music/Jazz") otherParent := model.NewFolder(testLib, "TestParent2/Music/Jazz") - Expect(repo.Put(ctx, parent)).To(Succeed()) - Expect(repo.Put(ctx, child1)).To(Succeed()) - Expect(repo.Put(ctx, child2)).To(Succeed()) + Expect(repo.Put(parent)).To(Succeed()) + Expect(repo.Put(child1)).To(Succeed()) + Expect(repo.Put(child2)).To(Succeed()) // Query the parent folder - should return parent and all children - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestParent/Music") + results, err := repo.GetFolderUpdateInfo(testLib, "TestParent/Music") Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) Expect(results).To(HaveKey(parent.ID)) @@ -157,22 +122,23 @@ var _ = Describe("FolderRepository", func() { }) It("excludes children from other libraries", func() { + tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create parent in testLib parent := model.NewFolder(testLib, "TestIsolation/Parent") child := model.NewFolder(testLib, "TestIsolation/Parent/Child") - Expect(repo.Put(ctx, parent)).To(Succeed()) - Expect(repo.Put(ctx, child)).To(Succeed()) + Expect(repo.Put(parent)).To(Succeed()) + Expect(repo.Put(child)).To(Succeed()) // Create similar path in other library otherParent := model.NewFolder(otherLib, "TestIsolation/Parent") otherChild := model.NewFolder(otherLib, "TestIsolation/Parent/Child") - Expect(repo.Put(ctx, otherParent)).To(Succeed()) - Expect(repo.Put(ctx, otherChild)).To(Succeed()) + Expect(repo.Put(otherParent)).To(Succeed()) + Expect(repo.Put(otherChild)).To(Succeed()) // Query should only return folders from testLib - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestIsolation/Parent") + results, err := repo.GetFolderUpdateInfo(testLib, "TestIsolation/Parent") Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) Expect(results).To(HaveKey(parent.ID)) @@ -182,18 +148,19 @@ var _ = Describe("FolderRepository", func() { }) It("excludes missing children when querying parent", func() { + tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create parent and children, mark one as missing parent := model.NewFolder(testLib, "TestMissingChild/Parent") child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1") child2 := model.NewFolder(testLib, "TestMissingChild/Parent/Child2") child2.Missing = true - Expect(repo.Put(ctx, parent)).To(Succeed()) - Expect(repo.Put(ctx, child1)).To(Succeed()) - Expect(repo.Put(ctx, child2)).To(Succeed()) + Expect(repo.Put(parent)).To(Succeed()) + Expect(repo.Put(child1)).To(Succeed()) + Expect(repo.Put(child2)).To(Succeed()) // Query parent - should only return parent and non-missing child - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestMissingChild/Parent") + results, err := repo.GetFolderUpdateInfo(testLib, "TestMissingChild/Parent") Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) Expect(results).To(HaveKey(parent.ID)) @@ -202,15 +169,16 @@ var _ = Describe("FolderRepository", func() { }) It("handles mix of existing and non-existing target paths", func() { + tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create folders for one path but not the other existingParent := model.NewFolder(testLib, "TestMixed/Exists") existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child") - Expect(repo.Put(ctx, existingParent)).To(Succeed()) - Expect(repo.Put(ctx, existingChild)).To(Succeed()) + Expect(repo.Put(existingParent)).To(Succeed()) + Expect(repo.Put(existingChild)).To(Succeed()) // Query both existing and non-existing paths - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestMixed/Exists", "TestMixed/DoesNotExist") + results, err := repo.GetFolderUpdateInfo(testLib, "TestMixed/Exists", "TestMixed/DoesNotExist") Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) Expect(results).To(HaveKey(existingParent.ID)) @@ -221,7 +189,7 @@ var _ = Describe("FolderRepository", func() { // Test querying for root folder without creating it (fixtures should have one) rootFolderID := model.FolderID(testLib, ".") - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "") + results, err := repo.GetFolderUpdateInfo(testLib, "") Expect(err).ToNot(HaveOccurred()) // Should return the root folder if it exists if len(results) > 0 { @@ -230,7 +198,7 @@ var _ = Describe("FolderRepository", func() { }) It("returns empty map for non-existent folders", func() { - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "NonExistent/Path") + results, err := repo.GetFolderUpdateInfo(testLib, "NonExistent/Path") Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -239,84 +207,16 @@ var _ = Describe("FolderRepository", func() { // Create a folder and mark it as missing folder := model.NewFolder(testLib, "TestMissing/Folder") folder.Missing = true - err := repo.Put(ctx, folder) + err := repo.Put(folder) Expect(err).ToNot(HaveOccurred()) - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestMissing/Folder") + results, err := repo.GetFolderUpdateInfo(testLib, "TestMissing/Folder") Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) }) }) - 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(ctx, f)).To(Succeed()) - } - }) - - It("returns false when all audio under the parent belongs to the given folders", func() { - Expect(repo.HasAudioOutsideFolders(ctx, *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(ctx, bonus)).To(Succeed()) - - Expect(repo.HasAudioOutsideFolders(ctx, *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(ctx, *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(ctx, other)).To(Succeed()) - - Expect(repo.HasAudioOutsideFolders(ctx, *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(ctx, gone)).To(Succeed()) - - Expect(repo.HasAudioOutsideFolders(ctx, *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(ctx, wildcardRoot)).To(Succeed()) - - Expect(repo.HasAudioOutsideFolders(ctx, *wildcardRoot, []string{"none"})).To(BeFalse()) - }) - }) - - Describe("dbFolder.String", func() { - It("does not dereference a nil Folder", func() { - Expect(fmt.Sprint(dbFolder{})).To(Equal("")) - Expect(fmt.Sprint(&dbFolder{})).To(Equal("")) - }) - }) - 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: @@ -336,7 +236,7 @@ var _ = Describe("FolderRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Folder")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil folder")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) @@ -356,36 +256,4 @@ 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(ctx, withPls)).To(Succeed()) - Expect(repo.Put(ctx, noPls)).To(Succeed()) - Expect(repo.Put(ctx, 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(ctx) - 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 d88cb8672..53f324bf4 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -13,39 +13,40 @@ type genreRepository struct { *baseTagRepository } -func NewGenreRepository(db dbx.Builder) model.GenreRepository { +func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository { + genreFilter := model.TagGenre return &genreRepository{ - baseTagRepository: newBaseTagRepository(db, new(model.TagGenre)), + baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter), } } -func (r *genreRepository) selectGenre(ctx context.Context, opt ...model.QueryOptions) SelectBuilder { - return r.newSelect(ctx, opt...).Columns("tag.tag_value as name") +func (r *genreRepository) selectGenre(opt ...model.QueryOptions) SelectBuilder { + return r.newSelect(opt...).Columns("tag.tag_value as name") } -func (r *genreRepository) GetAll(ctx context.Context, opt ...model.QueryOptions) (model.Genres, error) { - sq := r.selectGenre(ctx, opt...) +func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error) { + sq := r.selectGenre(opt...) res := model.Genres{} - err := r.queryAll(ctx, sq, &res) + err := r.queryAll(sq, &res) return res, err } -func (r *genreRepository) Get(ctx context.Context, id string) (*model.Genre, error) { - sel := r.selectGenre(ctx).Where(Eq{"tag.id": id}) +// Override ResourceRepository methods to return Genre objects instead of Tag objects + +func (r *genreRepository) Read(id string) (any, error) { + sel := r.selectGenre().Where(Eq{"tag.id": id}) var res model.Genre - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -// Override the base tag REST methods to return Genre objects instead of Tag objects - -func (r *genreRepository) Read(ctx context.Context, id string) (*model.Genre, error) { - return r.Get(ctx, id) +func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *genreRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Genre, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *genreRepository) NewInstance() any { + return &model.Genre{} } var _ model.GenreRepository = (*genreRepository)(nil) -var _ rest.Repository[model.Genre] = (*genreRepository)(nil) +var _ model.ResourceRepository = (*genreRepository)(nil) diff --git a/persistence/genre_repository_test.go b/persistence/genre_repository_test.go index 52bebf446..e3779725c 100644 --- a/persistence/genre_repository_test.go +++ b/persistence/genre_repository_test.go @@ -16,16 +16,16 @@ import ( var _ = Describe("GenreRepository", func() { var repo model.GenreRepository - var restRepo rest.Repository[model.Genre] + var restRepo model.ResourceRepository var tagRepo model.TagRepository var ctx context.Context BeforeEach(func() { ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) - genreRepo := NewGenreRepository(GetDBXBuilder()) + genreRepo := NewGenreRepository(ctx, GetDBXBuilder()) repo = genreRepo - restRepo = genreRepo - tagRepo = NewTagRepository(GetDBXBuilder()) + restRepo = genreRepo.(model.ResourceRepository) + tagRepo = NewTagRepository(ctx, GetDBXBuilder()) // Clear any existing tags to ensure test isolation db := GetDBXBuilder() @@ -43,7 +43,7 @@ var _ = Describe("GenreRepository", func() { return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} } - err = tagRepo.Add(ctx, 1, + err = tagRepo.Add(1, newTag("genre", "rock"), newTag("genre", "pop"), newTag("genre", "jazz"), @@ -65,7 +65,7 @@ var _ = Describe("GenreRepository", func() { Describe("GetAll", func() { It("should return all genres", func() { - genres, err := repo.GetAll(ctx) + genres, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(genres).To(HaveLen(12)) @@ -83,7 +83,7 @@ var _ = Describe("GenreRepository", func() { It("should support query options", func() { // Test with limiting results - genres, err := repo.GetAll(ctx, model.QueryOptions{Max: 1}) + genres, err := repo.GetAll(model.QueryOptions{Max: 1}) Expect(err).ToNot(HaveOccurred()) Expect(genres).To(HaveLen(1)) }) @@ -93,7 +93,7 @@ var _ = Describe("GenreRepository", func() { _, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute() Expect(err).ToNot(HaveOccurred()) - genres, err := repo.GetAll(ctx) + genres, err := repo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(genres).To(BeEmpty()) }) @@ -103,7 +103,7 @@ var _ = Describe("GenreRepository", func() { options := model.QueryOptions{ Filters: squirrel.Like{"tag_value": "%rock%"}, // Direct field access } - genres, err := repo.GetAll(ctx, options) + genres, err := repo.GetAll(options) Expect(err).ToNot(HaveOccurred()) Expect(genres).To(HaveLen(2)) // Should match "rock" and "Alternative Rock" @@ -119,7 +119,7 @@ var _ = Describe("GenreRepository", func() { Filters: squirrel.Like{"tag_value": "%e%"}, // Should match genres containing "e" Sort: "name", } - genres, err := repo.GetAll(ctx, options) + genres, err := repo.GetAll(options) Expect(err).ToNot(HaveOccurred()) Expect(genres).To(HaveLen(7)) @@ -135,7 +135,7 @@ var _ = Describe("GenreRepository", func() { Sort: "name", Order: "desc", } - genres, err := repo.GetAll(ctx, options) + genres, err := repo.GetAll(options) Expect(err).ToNot(HaveOccurred()) Expect(genres).To(HaveLen(7)) @@ -148,7 +148,7 @@ var _ = Describe("GenreRepository", func() { Describe("Count", func() { It("should return correct count of genres", func() { - count, err := restRepo.Count(ctx) + count, err := restRepo.Count() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(12))) // We have 12 genre tags }) @@ -158,7 +158,7 @@ var _ = Describe("GenreRepository", func() { _, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute() Expect(err).ToNot(HaveOccurred()) - count, err := restRepo.Count(ctx) + count, err := restRepo.Count() Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeZero()) }) @@ -170,10 +170,10 @@ var _ = Describe("GenreRepository", func() { TagName: "mood", TagValue: "energetic", } - err := tagRepo.Add(ctx, 1, nonGenreTag) + err := tagRepo.Add(1, nonGenreTag) Expect(err).ToNot(HaveOccurred()) - count, err := restRepo.Count(ctx) + count, err := restRepo.Count() Expect(err).ToNot(HaveOccurred()) // Count should not include the mood tag Expect(count).To(Equal(int64(12))) // Should still be 12 genre tags @@ -184,7 +184,7 @@ var _ = Describe("GenreRepository", func() { options := rest.QueryOptions{ Filters: map[string]any{"name": "%rock%"}, } - count, err := restRepo.Count(ctx, options) + count, err := restRepo.Count(options) Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeNumerically("==", 2)) }) @@ -194,28 +194,30 @@ var _ = Describe("GenreRepository", func() { It("should return existing genre", func() { // Use one of the existing genres from our consolidated dataset genreID := id.NewTagID("genre", "rock") - genre, err := restRepo.Read(ctx, genreID) + result, err := restRepo.Read(genreID) Expect(err).ToNot(HaveOccurred()) + genre := result.(*model.Genre) Expect(genre.ID).To(Equal(genreID)) Expect(genre.Name).To(Equal("rock")) }) It("should return error for non-existent genre", func() { - _, err := restRepo.Read(ctx, "non-existent-id") + _, err := restRepo.Read("non-existent-id") Expect(err).To(HaveOccurred()) }) It("should not return non-genre tags", func() { moodID := id.NewTagID("mood", "happy") // This exists as a mood tag, not genre - _, err := restRepo.Read(ctx, moodID) + _, err := restRepo.Read(moodID) Expect(err).To(HaveOccurred()) // Should not find it as a genre }) }) Describe("ReadAll", func() { It("should return all genres through ReadAll", func() { - genres, err := restRepo.ReadAll(ctx) + result, err := restRepo.ReadAll() Expect(err).ToNot(HaveOccurred()) + genres := result.(model.Genres) Expect(genres).To(HaveLen(12)) // We have 12 genre tags genreNames := make([]string, len(genres)) @@ -229,7 +231,7 @@ var _ = Describe("GenreRepository", func() { }) It("should support rest query options", func() { - result, err := restRepo.ReadAll(ctx) + result, err := restRepo.ReadAll() Expect(err).ToNot(HaveOccurred()) Expect(result).ToNot(BeNil()) }) @@ -238,15 +240,13 @@ var _ = Describe("GenreRepository", func() { Describe("Library Filtering", func() { Context("Headless Processes (No User Context)", func() { var headlessRepo model.GenreRepository - var headlessRestRepo rest.Repository[model.Genre] - var headlessCtx context.Context + var headlessRestRepo model.ResourceRepository BeforeEach(func() { - headlessCtx = GinkgoT().Context() // Create a repository with no user context (headless) - headlessGenreRepo := NewGenreRepository(GetDBXBuilder()) + headlessGenreRepo := NewGenreRepository(context.Background(), GetDBXBuilder()) headlessRepo = headlessGenreRepo - headlessRestRepo = headlessGenreRepo + headlessRestRepo = headlessGenreRepo.(model.ResourceRepository) // Add genres to different libraries db := GetDBXBuilder() @@ -258,13 +258,13 @@ var _ = Describe("GenreRepository", func() { return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} } - err = tagRepo.Add(ctx, 2, newTag("genre", "jazz")) + err = tagRepo.Add(2, newTag("genre", "jazz")) Expect(err).ToNot(HaveOccurred()) }) It("should see all genres from all libraries when no user is in context", func() { // Headless processes should see all genres regardless of library - genres, err := headlessRepo.GetAll(headlessCtx) + genres, err := headlessRepo.GetAll() Expect(err).ToNot(HaveOccurred()) // Should see genres from all libraries @@ -279,7 +279,7 @@ var _ = Describe("GenreRepository", func() { }) It("should count all genres from all libraries when no user is in context", func() { - count, err := headlessRestRepo.Count(headlessCtx) + count, err := headlessRestRepo.Count() Expect(err).ToNot(HaveOccurred()) // Should count all genres from all libraries @@ -288,11 +288,12 @@ var _ = Describe("GenreRepository", func() { It("should allow headless processes to apply explicit library_id filters", func() { // Filter by specific library - genreList, err := headlessRestRepo.ReadAll(headlessCtx, rest.QueryOptions{ + genres, err := headlessRestRepo.ReadAll(rest.QueryOptions{ Filters: map[string]any{"library_id": 2}, }) Expect(err).ToNot(HaveOccurred()) + genreList := genres.(model.Genres) // Should see only genres from library 2 Expect(genreList).To(HaveLen(1)) Expect(genreList[0].Name).To(Equal("jazz")) @@ -300,15 +301,29 @@ var _ = Describe("GenreRepository", func() { It("should get individual genres when no user is in context", func() { // Get all genres first to find an ID - genres, err := headlessRepo.GetAll(headlessCtx) + genres, err := headlessRepo.GetAll() Expect(err).ToNot(HaveOccurred()) Expect(genres).ToNot(BeEmpty()) // Headless process should be able to get the genre - genre, err := headlessRestRepo.Read(headlessCtx, genres[0].ID) + genre, err := headlessRestRepo.Read(genres[0].ID) Expect(err).ToNot(HaveOccurred()) Expect(genre).ToNot(BeNil()) }) }) }) + + Describe("EntityName", func() { + It("should return correct entity name", func() { + name := restRepo.EntityName() + Expect(name).To(Equal("tag")) // Genre repository uses tag table + }) + }) + + Describe("NewInstance", func() { + It("should return new genre instance", func() { + instance := restRepo.NewInstance() + Expect(instance).To(BeAssignableToTypeOf(&model.Genre{})) + }) + }) }) diff --git a/persistence/helpers.go b/persistence/helpers.go index 1da31cf02..fd6a9a4cd 100644 --- a/persistence/helpers.go +++ b/persistence/helpers.go @@ -9,8 +9,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/fatih/structs" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/db" ) type PostMapper interface { @@ -84,28 +82,11 @@ func (e existsCond) ToSql() (string, []any, error) { var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`) -// naturalSort makes a plain text column sort numbers by value, leaving it alone -// otherwise so it keeps its declared collation. Parens guard buildSortOrder's space split. -func naturalSort(col string) string { - if !conf.Server.EnableNaturalSorting { - return col - } - return fmt.Sprintf("(%s collate %s)", col, db.NaturalCollation) -} - -// Convert the order_* columns to a collated sort expression, falling back to the -// sort_* column when those are preferred. Example: -// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase) +// Convert the order_* columns to an expression using sort_* columns. Example: +// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase) // It finds order column names anywhere in the substring func mapSortOrder(tableName, order string) string { - col := tableName + ".order_$1" - if conf.Server.PreferSortTags { - col = fmt.Sprintf("coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1)", tableName) - } - collation := "nocase" - if conf.Server.EnableNaturalSorting { - collation = db.NaturalCollation - } - repl := fmt.Sprintf("(%s collate %s)", col, collation) - return sortOrderRegex.ReplaceAllString(strings.ToLower(order), repl) + order = strings.ToLower(order) + repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName) + return sortOrderRegex.ReplaceAllString(order, repl) } diff --git a/persistence/helpers_test.go b/persistence/helpers_test.go index 3019609f3..85893ef55 100644 --- a/persistence/helpers_test.go +++ b/persistence/helpers_test.go @@ -4,8 +4,6 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -87,51 +85,22 @@ var _ = Describe("Helpers", func() { }) Describe("mapSortOrder", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - It("does not change the sort string if there are no order columns", func() { - Expect(mapSortOrder("album", "album_name asc")).To(Equal("album_name asc")) + sort := "album_name asc" + mapped := mapSortOrder("album", sort) + Expect(mapped).To(Equal(sort)) + }) + It("changes order columns to sort expression", func() { + sort := "ORDER_ALBUM_NAME asc" + mapped := mapSortOrder("album", sort) + Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` + + ` collate nocase) asc`)) }) - - DescribeTable("maps order columns to a collated expression", - func(preferSortTags, naturalSorting bool, expected string) { - conf.Server.PreferSortTags = preferSortTags - conf.Server.EnableNaturalSorting = naturalSorting - Expect(mapSortOrder("album", "ORDER_ALBUM_NAME asc")).To(Equal(expected)) - }, - Entry("qualified column", false, false, - "(album.order_album_name collate nocase) asc"), - Entry("natural collation", false, true, - "(album.order_album_name collate NATSORT) asc"), - Entry("sort tags preferred", true, false, - `(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate nocase) asc`), - Entry("sort tags preferred, natural collation", true, true, - `(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate NATSORT) asc`), - ) - It("changes multiple order columns to sort expressions", func() { - conf.Server.PreferSortTags = true sort := "compilation, order_title asc, order_album_artist_name desc, year desc" - Expect(mapSortOrder("album", sort)).To(Equal( - `compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` + - ` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`)) - }) - }) - - Describe("naturalSort", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("leaves the column alone by default, keeping its declared collation", func() { - Expect(naturalSort("media_file.title")).To(Equal("media_file.title")) - }) - - It("applies the natural collation when enabled", func() { - conf.Server.EnableNaturalSorting = true - Expect(naturalSort("media_file.title")).To(Equal("(media_file.title collate NATSORT)")) + mapped := mapSortOrder("album", sort) + Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` + + ` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`)) }) }) }) diff --git a/persistence/item_tags_test.go b/persistence/item_tags_test.go deleted file mode 100644 index 1106c7e9c..000000000 --- a/persistence/item_tags_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/deluan/rest" - "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("item genre tag indexes", func() { - var conn *dbx.DB - var mr model.MediaFileRepository - var ar model.AlbumRepository - var rock, jazz model.Tag - var ctx context.Context - - BeforeEach(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}) - conn = GetDBXBuilder() - mr = NewMediaFileRepository(conn) - ar = NewAlbumRepository(conn) - // Test-only genre values, so they can't collide with the golden fixtures. - rock = model.NewTag(model.TagGenre, "GenreIdxRock") - jazz = model.NewTag(model.TagGenre, "GenreIdxJazz") - // The join tables FK to tag(id); the scanner adds tags before saving items. - Expect(NewTagRepository(conn).Add(ctx, 1, rock, jazz)).To(Succeed()) - // The suite shares one golden DB with no per-test restore, so undo the rows we add - // (media_file/album deletes cascade to the *_tags join rows; tag deletes cascade too). - DeferCleanup(func() { - _, _ = conn.NewQuery("DELETE FROM media_file WHERE id LIKE 'mf-%'").Execute() - _, _ = conn.NewQuery("DELETE FROM album WHERE id LIKE 'al-%'").Execute() - _, _ = conn.NewQuery("DELETE FROM tag WHERE id={:r} OR id={:j}"). - Bind(dbx.Params{"r": rock.ID, "j": jazz.ID}).Execute() - }) - }) - - tagIDsFor := func(table, col, id string) []string { - var rows []struct { - TagID string `db:"tag_id"` - } - err := conn.NewQuery("SELECT tag_id FROM " + table + " WHERE " + col + "={:id}"). - Bind(dbx.Params{"id": id}).All(&rows) - Expect(err).ToNot(HaveOccurred()) - ids := make([]string, len(rows)) - for i, r := range rows { - ids[i] = r.TagID - } - return ids - } - - Describe("media files", func() { - It("writes a media_file_tags row for each genre when the track is saved", func() { - mf := model.MediaFile{ID: "mf-g1", LibraryID: 1, Path: "/m/g1.mp3", Title: "G1", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g1")).To(ConsistOf(rock.ID, jazz.ID)) - }) - - It("replaces the rows when the genres change", func() { - mf := model.MediaFile{ID: "mf-g2", LibraryID: 1, Path: "/m/g2.mp3", Title: "G2", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - mf.Tags = model.Tags{model.TagGenre: []string{jazz.TagValue}} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g2")).To(ConsistOf(jazz.ID)) - }) - - It("clears the rows when all genres are removed", func() { - mf := model.MediaFile{ID: "mf-g3", LibraryID: 1, Path: "/m/g3.mp3", Title: "G3", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - mf.Tags = model.Tags{} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g3")).To(BeEmpty()) - }) - }) - - Describe("albums", func() { - It("writes an album_tags row for each genre when the album is saved", func() { - al := model.Album{ID: "al-g1", LibraryID: 1, Name: "AG1", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}} - Expect(ar.Put(ctx, &al)).To(Succeed()) - Expect(tagIDsFor("album_tags", "album_id", "al-g1")).To(ConsistOf(rock.ID, jazz.ID)) - }) - }) - - // The native (REST) API filters by genre_id; it must resolve through the join table too. - Describe("native genre_id filter", func() { - It("filters media files by genre_id", func() { - mf := model.MediaFile{ID: "mf-nat1", LibraryID: 1, Path: "/m/nat1.mp3", Title: "Nat1", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - res, err := mr.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(ContainElement(HaveField("ID", "mf-nat1"))) - }) - - It("filters albums by genre_id", func() { - al := model.Album{ID: "al-nat1", LibraryID: 1, Name: "ANat1", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(ar.Put(ctx, &al)).To(Succeed()) - res, err := ar.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(ContainElement(HaveField("ID", "al-nat1"))) - }) - }) -}) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 85da65cbc..1d8e6f35e 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -25,21 +25,22 @@ var ( libLock sync.RWMutex ) -func NewLibraryRepository(db dbx.Builder) model.LibraryRepository { +func NewLibraryRepository(ctx context.Context, db dbx.Builder) model.LibraryRepository { r := &libraryRepository{} + r.ctx = ctx r.db = db r.registerModel(&model.Library{}, nil) return r } -func (r *libraryRepository) Get(ctx context.Context, id int) (*model.Library, error) { - sq := r.newSelect(ctx).Columns("*").Where(Eq{"id": id}) +func (r *libraryRepository) Get(id int) (*model.Library, error) { + sq := r.newSelect().Columns("*").Where(Eq{"id": id}) var res model.Library - err := r.queryOne(ctx, sq, &res) + err := r.queryOne(sq, &res) return &res, err } -func (r *libraryRepository) GetPath(ctx context.Context, id int) (string, error) { +func (r *libraryRepository) GetPath(id int) (string, error) { l := func() string { libLock.RLock() defer libLock.RUnlock() @@ -54,9 +55,9 @@ func (r *libraryRepository) GetPath(ctx context.Context, id int) (string, error) libLock.Lock() defer libLock.Unlock() - libs, err := r.GetAll(ctx) + libs, err := r.GetAll() if err != nil { - log.Error(ctx, "Error loading libraries from DB", err) + log.Error(r.ctx, "Error loading libraries from DB", err) return "", err } for _, l := range libs { @@ -69,9 +70,9 @@ func (r *libraryRepository) GetPath(ctx context.Context, id int) (string, error) } } -func (r *libraryRepository) Put(ctx context.Context, l *model.Library, colsToUpdate ...string) error { +func (r *libraryRepository) Put(l *model.Library) error { if l.ID == model.DefaultLibraryID { - currentLib, err := r.Get(ctx, 1) + currentLib, err := r.Get(1) // if we are creating it, it's ok. if err == nil { // it exists, so we are updating it if currentLib.Path != l.Path { @@ -88,17 +89,15 @@ func (r *libraryRepository) Put(ctx context.Context, l *model.Library, colsToUpd err = r.db.Model(l).Insert() } else { // Try to update first - cols := selectUpdateColumns(map[string]any{ + cols := map[string]any{ "name": l.Name, "path": l.Path, "remote_path": l.RemotePath, "default_new_users": l.DefaultNewUsers, - "pid_album": l.PIDAlbum, - "pid_track": l.PIDTrack, - }, colsToUpdate...) - cols["updated_at"] = l.UpdatedAt + "updated_at": l.UpdatedAt, + } sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) - rowsAffected, updateErr := r.executeSQL(ctx, sq) + rowsAffected, updateErr := r.executeSQL(sq) if updateErr != nil { return updateErr } @@ -123,7 +122,7 @@ CROSS JOIN library l WHERE u.is_admin = true ON CONFLICT (user_id, library_id) DO NOTHING;`, ) - if _, err = r.executeSQL(ctx, sql); err != nil { + if _, err = r.executeSQL(sql); err != nil { return fmt.Errorf("failed to assign library to admin users: %w", err) } @@ -135,13 +134,12 @@ ON CONFLICT (user_id, library_id) DO NOTHING;`, // TODO Remove this method when we have a proper UI to add libraries // This is a temporary method to store the music folder path from the config in the DB -func (r *libraryRepository) StoreMusicFolder(ctx context.Context) error { +func (r *libraryRepository) StoreMusicFolder() error { sq := Update(r.tableName).Set("path", conf.Server.MusicFolder). Set("updated_at", time.Now()). - Where(Eq{"id": model.DefaultLibraryID}). - Where(NotEq{"path": conf.Server.MusicFolder}) - rowsAffected, err := r.executeSQL(ctx, sq) - if err == nil && rowsAffected > 0 { + Where(Eq{"id": model.DefaultLibraryID}) + _, err := r.executeSQL(sq) + if err != nil { libLock.Lock() defer libLock.Unlock() libCache[model.DefaultLibraryID] = conf.Server.MusicFolder @@ -149,86 +147,86 @@ func (r *libraryRepository) StoreMusicFolder(ctx context.Context) error { return err } -func (r *libraryRepository) AddArtist(ctx context.Context, id int, artistID string) error { +func (r *libraryRepository) AddArtist(id int, artistID string) error { sq := Insert("library_artist").Columns("library_id", "artist_id").Values(id, artistID). Suffix(`on conflict(library_id, artist_id) do nothing`) - _, err := r.executeSQL(ctx, sq) + _, err := r.executeSQL(sq) if err != nil { return err } return nil } -func (r *libraryRepository) ScanBegin(ctx context.Context, id int, fullScan bool) error { +func (r *libraryRepository) ScanBegin(id int, fullScan bool) error { sq := Update(r.tableName). Set("last_scan_started_at", time.Now()). Set("full_scan_in_progress", fullScan). Where(Eq{"id": id}) - _, err := r.executeSQL(ctx, sq) + _, err := r.executeSQL(sq) return err } -func (r *libraryRepository) ScanEnd(ctx context.Context, id int) error { +func (r *libraryRepository) ScanEnd(id int) error { sq := Update(r.tableName). Set("last_scan_at", time.Now()). Set("full_scan_in_progress", false). Set("last_scan_started_at", time.Time{}). Where(Eq{"id": id}) - _, err := r.executeSQL(ctx, sq) + _, 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 } -func (r *libraryRepository) SetScannedPID(ctx context.Context, id int, pid model.PIDConfig) error { - sq := Update(r.tableName). - Set("scanned_pid_album", pid.Album). - Set("scanned_pid_track", pid.Track). - Where(Eq{"id": id}) - _, err := r.executeSQL(ctx, sq) - return err -} - -func (r *libraryRepository) ScanInProgress(ctx context.Context) (bool, error) { - query := r.newSelect(ctx).Where(NotEq{"last_scan_started_at": time.Time{}}) - count, err := r.count(ctx, query) +func (r *libraryRepository) ScanInProgress() (bool, error) { + query := r.newSelect().Where(NotEq{"last_scan_started_at": time.Time{}}) + count, err := r.count(query) return count > 0, err } -func (r *libraryRepository) RefreshStats(ctx context.Context, id int) error { +func (r *libraryRepository) RefreshStats(id int) error { var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 } var sizeRes struct{ Sum int64 } var durationRes struct{ Sum float64 } err := run.Parallel( func() error { - return r.queryOne(ctx, Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": false}), &songsRes) + return r.queryOne(Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": false}), &songsRes) }, func() error { - return r.queryOne(ctx, Select("count(*) as count").From("album").Where(Eq{"library_id": id, "missing": false}), &albumsRes) + return r.queryOne(Select("count(*) as count").From("album").Where(Eq{"library_id": id, "missing": false}), &albumsRes) }, func() error { - return r.queryOne(ctx, Select("count(*) as count").From("library_artist la"). + return r.queryOne(Select("count(*) as count").From("library_artist la"). Join("artist a on la.artist_id = a.id"). Where(Eq{"la.library_id": id, "a.missing": false}), &artistsRes) }, func() error { - return r.queryOne(ctx, Select("count(*) as count").From("folder"). + return r.queryOne(Select("count(*) as count").From("folder"). Where(And{ Eq{"library_id": id, "missing": false}, Gt{"num_audio_files": 0}, }), &foldersRes) }, func() error { - return r.queryOne(ctx, Select("ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count"). + return r.queryOne(Select("ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count"). From("folder").Where(Eq{"library_id": id, "missing": false}), &filesRes) }, func() error { - return r.queryOne(ctx, Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": true}), &missingRes) + return r.queryOne(Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": true}), &missingRes) }, func() error { - return r.queryOne(ctx, Select("ifnull(sum(size),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &sizeRes) + return r.queryOne(Select("ifnull(sum(size),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &sizeRes) }, func() error { - return r.queryOne(ctx, Select("ifnull(sum(duration),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &durationRes) + return r.queryOne(Select("ifnull(sum(duration),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &durationRes) }, )() if err != nil { @@ -246,28 +244,23 @@ func (r *libraryRepository) RefreshStats(ctx context.Context, id int) error { Set("total_duration", durationRes.Sum). Set("updated_at", time.Now()). Where(Eq{"id": id}) - _, err = r.executeSQL(ctx, sq) + _, err = r.executeSQL(sq) return err } -func (r *libraryRepository) Delete(ctx context.Context, id int) error { - if !loggedUser(ctx).IsAdmin { +func (r *libraryRepository) Delete(id int) error { + if !loggedUser(r.ctx).IsAdmin { return model.ErrNotAuthorized } if id == 1 { return fmt.Errorf("%w: library with ID 1 cannot be deleted", model.ErrValidation) } - err := r.delete(ctx, Eq{"id": id}) + err := r.delete(Eq{"id": id}) if err != nil { return err } - // The cascade above can drop an artist's last library_artist row; reconcile any such orphans. - if err := NewArtistRepository(r.db).(*artistRepository).markOrphansMissing(ctx); 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() @@ -275,26 +268,26 @@ func (r *libraryRepository) Delete(ctx context.Context, id int) error { // Clean up orphaned plugin references for the deleted library if err := cleanupPluginLibraryReferences(r.db, id); err != nil { - log.Error(ctx, "Failed to cleanup plugin library references", "libraryID", id, err) + log.Error(r.ctx, "Failed to cleanup plugin library references", "libraryID", id, err) } return nil } -func (r *libraryRepository) GetAll(ctx context.Context, ops ...model.QueryOptions) (model.Libraries, error) { - sq := r.newSelect(ctx, ops...).Columns("*") +func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) { + sq := r.newSelect(ops...).Columns("*") res := model.Libraries{} - err := r.queryAll(ctx, sq, &res) + err := r.queryAll(sq, &res) return res, err } -func (r *libraryRepository) CountAll(ctx context.Context, ops ...model.QueryOptions) (int64, error) { - sq := r.newSelect(ctx, ops...) - return r.count(ctx, sq) +func (r *libraryRepository) CountAll(ops ...model.QueryOptions) (int64, error) { + sq := r.newSelect(ops...) + return r.count(sq) } // User-library association methods -func (r *libraryRepository) GetUsersWithLibraryAccess(ctx context.Context, libraryID int) (model.Users, error) { +func (r *libraryRepository) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) { sel := Select("u.*"). From("user u"). Join("user_library ul ON u.id = ul.user_id"). @@ -302,28 +295,57 @@ func (r *libraryRepository) GetUsersWithLibraryAccess(ctx context.Context, libra OrderBy("u.name") var res model.Users - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) return res, err } // REST interface methods -func (r *libraryRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *libraryRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *libraryRepository) Read(ctx context.Context, id string) (*model.Library, error) { +func (r *libraryRepository) Read(id string) (any, error) { idInt, err := strconv.Atoi(id) if err != nil { - log.Trace(ctx, "invalid library id: %s", id, err) + log.Trace(r.ctx, "invalid library id: %s", id, err) return nil, rest.ErrNotFound } - return r.Get(ctx, idInt) + return r.Get(idInt) } -func (r *libraryRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Library, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *libraryRepository) EntityName() string { + return "library" +} + +func (r *libraryRepository) NewInstance() any { + return &model.Library{} +} + +func (r *libraryRepository) Save(entity any) (string, error) { + lib := entity.(*model.Library) + lib.ID = 0 // Reset ID to ensure we create a new library + err := r.Put(lib) + if err != nil { + return "", err + } + return strconv.Itoa(lib.ID), nil +} + +func (r *libraryRepository) Update(id string, entity any, cols ...string) error { + lib := entity.(*model.Library) + idInt, err := strconv.Atoi(id) + if err != nil { + return fmt.Errorf("invalid library ID: %s", id) + } + + lib.ID = idInt + return r.Put(lib) } var _ model.LibraryRepository = (*libraryRepository)(nil) -var _ rest.Repository[model.Library] = (*libraryRepository)(nil) +var _ rest.Repository = (*libraryRepository)(nil) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index bf485a06f..de7161643 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -4,9 +4,6 @@ import ( "context" "time" - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -23,7 +20,7 @@ var _ = Describe("LibraryRepository", func() { BeforeEach(func() { ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) conn = GetDBXBuilder() - repo = NewLibraryRepository(conn) + repo = NewLibraryRepository(ctx, conn) }) AfterEach(func() { @@ -40,40 +37,20 @@ var _ = Describe("LibraryRepository", func() { Path: "/music/test", } - err := repo.Put(ctx, lib) + err := repo.Put(lib) Expect(err).ToNot(HaveOccurred()) Expect(lib.ID).To(BeNumerically(">", 0)) Expect(lib.CreatedAt).ToNot(BeZero()) Expect(lib.UpdatedAt).ToNot(BeZero()) // Verify it was inserted - savedLib, err := repo.Get(ctx, lib.ID) + savedLib, err := repo.Get(lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(savedLib.Name).To(Equal("Test Library")) Expect(savedLib.Path).To(Equal("/music/test")) }) }) - Context("when colsToUpdate is specified", func() { - It("only writes the requested columns", func() { - lib := &model.Library{ - Name: "Original Library", - Path: "/music/original", - RemotePath: "/remote/original", - DefaultNewUsers: true, - } - Expect(repo.Put(ctx, lib)).To(Succeed()) - - Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed", Path: lib.Path}, "name", "path")).To(Succeed()) - - saved, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(saved.Name).To(Equal("Renamed")) - Expect(saved.RemotePath).To(Equal("/remote/original")) - Expect(saved.DefaultNewUsers).To(BeTrue()) - }) - }) - Context("when ID is non-zero and record exists", func() { It("updates the existing record", func() { // First create a library @@ -82,7 +59,7 @@ var _ = Describe("LibraryRepository", func() { Name: "Original Library", Path: "/music/original", } - err := repo.Put(ctx, lib) + err := repo.Put(lib) Expect(err).ToNot(HaveOccurred()) originalID := lib.ID @@ -96,7 +73,7 @@ var _ = Describe("LibraryRepository", func() { // Now update it lib.Name = "Updated Library" lib.Path = "/music/updated" - err = repo.Put(ctx, lib) + err = repo.Put(lib) Expect(err).ToNot(HaveOccurred()) // Verify it was updated, not inserted @@ -105,7 +82,7 @@ var _ = Describe("LibraryRepository", func() { Expect(lib.UpdatedAt).To(BeTemporally(">", originalCreatedAt)) // Verify the changes were saved - savedLib, err := repo.Get(ctx, lib.ID) + savedLib, err := repo.Get(lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(savedLib.Name).To(Equal("Updated Library")) Expect(savedLib.Path).To(Equal("/music/updated")) @@ -121,18 +98,18 @@ var _ = Describe("LibraryRepository", func() { } // Ensure the record doesn't exist - _, err := repo.Get(ctx, 999) + _, err := repo.Get(999) Expect(err).To(HaveOccurred()) // Put should insert it - err = repo.Put(ctx, lib) + err = repo.Put(lib) Expect(err).ToNot(HaveOccurred()) Expect(lib.ID).To(Equal(999)) Expect(lib.CreatedAt).ToNot(BeZero()) Expect(lib.UpdatedAt).ToNot(BeZero()) // Verify it was inserted with the correct ID - savedLib, err := repo.Get(ctx, 999) + savedLib, err := repo.Get(999) Expect(err).ToNot(HaveOccurred()) Expect(savedLib.ID).To(Equal(999)) Expect(savedLib.Name).To(Equal("New Library with ID")) @@ -141,51 +118,11 @@ var _ = Describe("LibraryRepository", func() { }) }) - Describe("StoreMusicFolder", func() { - var libBefore *model.Library - - BeforeEach(func() { - var err error - libBefore, err = repo.Get(ctx, model.DefaultLibraryID) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(configtest.SetupConfig()) - DeferCleanup(func() { - _, _ = conn.NewQuery("update library set path = {:path}, updated_at = {:updated_at} where id = {:id}"). - Bind(dbx.Params{"path": libBefore.Path, "updated_at": libBefore.UpdatedAt, "id": model.DefaultLibraryID}). - Execute() - libLock.Lock() - defer libLock.Unlock() - delete(libCache, model.DefaultLibraryID) - }) - }) - - It("skips updating the default library when the configured path is unchanged", func() { - conf.Server.MusicFolder = libBefore.Path - Expect(repo.StoreMusicFolder(ctx)).To(Succeed()) - - libAfter, err := repo.Get(ctx, model.DefaultLibraryID) - Expect(err).ToNot(HaveOccurred()) - Expect(libAfter.Path).To(Equal(libBefore.Path)) - Expect(libAfter.UpdatedAt).To(Equal(libBefore.UpdatedAt)) - }) - - It("updates the default library only when the configured path changes", func() { - conf.Server.MusicFolder = libBefore.Path + "-updated" - Expect(repo.StoreMusicFolder(ctx)).To(Succeed()) - - libAfter, err := repo.Get(ctx, model.DefaultLibraryID) - Expect(err).ToNot(HaveOccurred()) - Expect(libAfter.Path).To(Equal(conf.Server.MusicFolder)) - Expect(libAfter.UpdatedAt).ToNot(Equal(libBefore.UpdatedAt)) - }) - }) - It("refreshes stats", func() { - libBefore, err := repo.Get(ctx, 1) + libBefore, err := repo.Get(1) Expect(err).ToNot(HaveOccurred()) - Expect(repo.RefreshStats(ctx, 1)).To(Succeed()) - libAfter, err := repo.Get(ctx, 1) + Expect(repo.RefreshStats(1)).To(Succeed()) + libAfter, err := repo.Get(1) Expect(err).ToNot(HaveOccurred()) Expect(libAfter.UpdatedAt).To(BeTemporally(">", libBefore.UpdatedAt)) @@ -221,16 +158,16 @@ var _ = Describe("LibraryRepository", func() { Name: "Test Scan Library", Path: "/music/test-scan", } - err := repo.Put(ctx, lib) + err := repo.Put(lib) Expect(err).ToNot(HaveOccurred()) }) DescribeTable("ScanBegin", func(fullScan bool, expectedFullScanInProgress bool) { - err := repo.ScanBegin(ctx, lib.ID, fullScan) + err := repo.ScanBegin(lib.ID, fullScan) Expect(err).ToNot(HaveOccurred()) - updatedLib, err := repo.Get(ctx, lib.ID) + updatedLib, err := repo.Get(lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(updatedLib.LastScanStartedAt).ToNot(BeZero()) Expect(updatedLib.FullScanInProgress).To(Equal(expectedFullScanInProgress)) @@ -241,15 +178,15 @@ var _ = Describe("LibraryRepository", func() { Context("ScanEnd", func() { BeforeEach(func() { - err := repo.ScanBegin(ctx, lib.ID, true) + err := repo.ScanBegin(lib.ID, true) Expect(err).ToNot(HaveOccurred()) }) It("sets LastScanAt and clears FullScanInProgress and LastScanStartedAt", func() { - err := repo.ScanEnd(ctx, lib.ID) + err := repo.ScanEnd(lib.ID) Expect(err).ToNot(HaveOccurred()) - updatedLib, err := repo.Get(ctx, lib.ID) + updatedLib, err := repo.Get(lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(updatedLib.LastScanAt).ToNot(BeZero()) Expect(updatedLib.FullScanInProgress).To(BeFalse()) @@ -257,94 +194,16 @@ var _ = Describe("LibraryRepository", func() { }) It("sets LastScanAt to be after LastScanStartedAt", func() { - libBefore, err := repo.Get(ctx, lib.ID) + libBefore, err := repo.Get(lib.ID) Expect(err).ToNot(HaveOccurred()) - err = repo.ScanEnd(ctx, lib.ID) + err = repo.ScanEnd(lib.ID) Expect(err).ToNot(HaveOccurred()) - libAfter, err := repo.Get(ctx, lib.ID) + libAfter, err := repo.Get(lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(libAfter.LastScanAt).To(BeTemporally(">=", libBefore.LastScanStartedAt)) }) }) }) - - Describe("PID config", func() { - It("stores the overrides, and Put never touches the scanned specs", func() { - lib := &model.Library{Name: "PID Library", Path: "/music/pid", PIDAlbum: "folder", PIDTrack: "title"} - Expect(repo.Put(ctx, lib)).To(Succeed()) - Expect(repo.SetScannedPID(ctx, lib.ID, model.PIDConfig{Album: "folder", Track: "title"})).To(Succeed()) - - // An update coming from the REST API has no scanned specs. It must not clear them - update := &model.Library{ID: lib.ID, Name: "PID Library", Path: "/music/pid", PIDTrack: "title"} - Expect(repo.Put(ctx, update)).To(Succeed()) - - saved, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(saved.PIDAlbum).To(BeEmpty()) - Expect(saved.PIDTrack).To(Equal("title")) - Expect(saved.ScannedPIDAlbum).To(Equal("folder")) - Expect(saved.ScannedPIDTrack).To(Equal("title")) - }) - - It("keeps the overrides when a partial update does not send them", func() { - lib := &model.Library{Name: "Partial", Path: "/music/partial", PIDAlbum: "folder", PIDTrack: "title"} - Expect(repo.Put(ctx, lib)).To(Succeed()) - - Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed"}, "name")).To(Succeed()) - - saved, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(saved.Name).To(Equal("Renamed")) - Expect(saved.PIDAlbum).To(Equal("folder")) - Expect(saved.PIDTrack).To(Equal("title")) - }) - }) - - Describe("Delete", func() { - var adminRepo model.LibraryRepository - var artistRepo model.ArtistRepository - var adminCtx context.Context - - 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(GinkgoT().Context()), adminUser) - adminRepo = NewLibraryRepository(conn) - artistRepo = NewArtistRepository(conn) - }) - - It("marks artists orphaned by the delete as missing", func() { - lib := model.Library{Name: "Doomed Library", Path: "/doomed"} - Expect(adminRepo.Put(adminCtx, &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(adminCtx, &orphanArtist)).To(Succeed()) - Expect(artistRepo.Put(adminCtx, &sharedArtist)).To(Succeed()) - Expect(adminRepo.AddArtist(adminCtx, lib.ID, orphanArtist.ID)).To(Succeed()) - Expect(adminRepo.AddArtist(adminCtx, lib.ID, sharedArtist.ID)).To(Succeed()) - Expect(adminRepo.AddArtist(adminCtx, 1, sharedArtist.ID)).To(Succeed()) - DeferCleanup(func() { - if raw, ok := artistRepo.(*artistRepository); ok { - _, _ = raw.executeSQL(adminCtx, squirrel.Delete("artist"). - Where(squirrel.Eq{"id": []string{orphanArtist.ID, sharedArtist.ID}})) - } - }) - - Expect(artistMissing(orphanArtist.ID)).To(BeFalse()) - - Expect(adminRepo.Delete(adminCtx, 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 b9a5c3374..264778ea0 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "iter" - "maps" "slices" "strconv" "strings" @@ -16,9 +15,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" "github.com/pocketbase/dbx" ) @@ -38,14 +35,6 @@ type dbMediaFile struct { RgTrackPeak *float64 `structs:"-" json:"-"` } -// String guards the promoted MediaFile.String(), which would dereference a nil MediaFile. -func (m dbMediaFile) String() string { - if m.MediaFile == nil { - return "" - } - return m.MediaFile.String() -} - func (m *dbMediaFile) PostScan() error { m.RGTrackGain = m.RgTrackGain m.RGTrackPeak = m.RgTrackPeak @@ -73,7 +62,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"] = str.NormalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist) + args["search_normalized"] = normalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist) args["tags"] = marshalTags(m.MediaFile.Tags) args["participants"] = marshalParticipants(m.MediaFile.Participants) return nil @@ -85,8 +74,9 @@ func (m dbMediaFiles) toModels() model.MediaFiles { return slice.Map(m, func(mf dbMediaFile) model.MediaFile { return *mf.MediaFile }) } -func NewMediaFileRepository(db dbx.Builder) model.MediaFileRepository { +func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFileRepository { r := &mediaFileRepository{} + r.ctx = ctx r.db = db r.tableName = "media_file" r.registerModel(&model.MediaFile{}, mediaFileFilter()) @@ -94,22 +84,12 @@ func NewMediaFileRepository(db dbx.Builder) model.MediaFileRepository { "title": "order_title", "artist": "order_artist_name, order_album_name, release_date, disc_number, track_number", "album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number", - "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"), + "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", "random": "random", "created_at": "media_file.created_at", "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 } @@ -120,11 +100,10 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"), "starred": annotationBoolFilter("starred"), "has_rating": annotationBoolFilter("rating"), - "genre_id": genreFilter(SongGenres), + "genre_id": tagIDFilter, "missing": booleanFilter, - "artists_id": mediaFileArtistFilter, + "artists_id": artistFilter, "library_id": libraryIdFilter, - "path": startsWithFilter("media_file.path"), } // Add all album tags as filters for tag := range model.TagMappings() { @@ -135,36 +114,29 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { return filters }) -func mediaFileArtistFilter(_ string, value any) Sqlizer { - return ParticipantIDFilter("media_file", value, model.RoleAlbumArtist, model.RoleArtist) -} - func mediaFileRecentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "media_file.updated_at, media_file.id" + return "media_file.updated_at" } - return "media_file.created_at, media_file.id" + return "media_file.created_at" } -func (r *mediaFileRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - query := r.newSelect(ctx) - query = r.applyLibraryFilter(ctx, 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(ctx, query, "media_file.id") - } - return r.count(ctx, query, options...) +func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { + query := r.newSelect() + query = r.withAnnotation(query, "media_file.id") + query = r.applyLibraryFilter(query) + return r.count(query, options...) } -func (r *mediaFileRepository) CountBySuffix(ctx context.Context, options ...model.QueryOptions) (map[string]int64, error) { - sel := r.newSelect(ctx, options...). +func (r *mediaFileRepository) CountBySuffix(options ...model.QueryOptions) (map[string]int64, error) { + sel := r.newSelect(options...). Columns("lower(suffix) as suffix", "count(*) as count"). GroupBy("lower(suffix)") var res []struct { Suffix string Count int64 } - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) if err != nil { return nil, err } @@ -175,42 +147,37 @@ func (r *mediaFileRepository) CountBySuffix(ctx context.Context, options ...mode return counts, nil } -func (r *mediaFileRepository) Exists(ctx context.Context, id string) (bool, error) { - // The exists() helper applies no library filter, so it would report rows the caller cannot see. - c, err := r.count(ctx, r.applyLibraryFilter(ctx, r.newSelect(ctx).Where(Eq{"media_file.id": id}))) - return c > 0, err +func (r *mediaFileRepository) Exists(id string) (bool, error) { + return r.exists(Eq{"media_file.id": id}) } -func (r *mediaFileRepository) Put(ctx context.Context, m *model.MediaFile) error { +func (r *mediaFileRepository) Put(m *model.MediaFile) error { if m.CreatedAt.IsZero() { m.CreatedAt = time.Now() } - id, err := r.putByMatch(ctx, Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m}) + id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m}) if err != nil { return err } m.ID = id - if err := r.updateParticipants(ctx, m.ID, m.Participants); err != nil { - return err - } - return r.updateTags(ctx, m.ID, m.Tags) + return r.updateParticipants(m.ID, m.Participants) } -func (r *mediaFileRepository) UpdateProbeData(ctx context.Context, id string, data string) error { - _, err := r.executeSQL(ctx, Update(r.tableName).Set("probe_data", data).Where(Eq{"id": id})) +func (r *mediaFileRepository) UpdateProbeData(id string, data string) error { + _, err := r.executeSQL(Update(r.tableName).Set("probe_data", data).Where(Eq{"id": id})) return err } -func (r *mediaFileRepository) selectMediaFile(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(ctx, options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). +func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { + sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id") - sql = r.withAnnotation(ctx, sql, "media_file.id") - sql = r.withBookmark(ctx, sql, "media_file.id") - return r.applyLibraryFilter(ctx, sql) + sql = r.withAnnotation(sql, "media_file.id") + sql = r.withBookmark(sql, "media_file.id") + return r.applyLibraryFilter(sql) } -func (r *mediaFileRepository) Get(ctx context.Context, id string) (*model.MediaFile, error) { - res, err := r.GetAll(ctx, model.QueryOptions{Filters: Eq{"media_file.id": id}}) +func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) { + res, err := r.GetAll(model.QueryOptions{Filters: Eq{"media_file.id": id}}) if err != nil { return nil, err } @@ -220,68 +187,26 @@ func (r *mediaFileRepository) Get(ctx context.Context, id string) (*model.MediaF return &res[0], nil } -func (r *mediaFileRepository) GetWithParticipants(ctx context.Context, id string) (*model.MediaFile, error) { - m, err := r.Get(ctx, id) +func (r *mediaFileRepository) GetWithParticipants(id string) (*model.MediaFile, error) { + m, err := r.Get(id) if err != nil { return nil, err } - m.Participants, err = r.getParticipants(ctx, m) + m.Participants, err = r.getParticipants(m) return m, err } -func (r *mediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { - sq := r.selectMediaFile(ctx, options...) +func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { + sq := r.selectMediaFile(options...) var res dbMediaFiles - err := r.queryAll(ctx, sq, &res, options...) + err := r.queryAll(sq, &res, options...) if err != nil { return nil, err } - mfs := res.toModels() - r.hydrateArtwork(ctx, mfs) - return mfs, nil + return res.toModels(), nil } -func (r *mediaFileRepository) hydrateArtwork(ctx context.Context, mfs model.MediaFiles) { - hydrateMediaFileArtwork(ctx, r.db, mfs) -} - -// 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(ctx context.Context, 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(ctx, rowidQuery) - rowidQuery = rowidQuery.OrderBy("random()") - if opt.Max > 0 { - rowidQuery = rowidQuery.Limit(uint64(opt.Max)) - } - - var rowids []int64 - if err := r.queryAllSlice(ctx, 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(ctx).Where(Eq{"media_file.rowid": rowids}).OrderBy("random()") - var res dbMediaFiles - if err := r.queryAll(ctx, sq, &res); err != nil { - return nil, err - } - mfs := res.toModels() - r.hydrateArtwork(ctx, mfs) - return mfs, nil -} - -func (r *mediaFileRepository) GetAllByTags(ctx context.Context, tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { +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)) for i, v := range values { @@ -303,148 +228,77 @@ func (r *mediaFileRepository) GetAllByTags(ctx context.Context, tag model.TagNam } else { opts.Filters = tagFilter } - return r.GetAll(ctx, opts) + return r.GetAll(opts) } -func (r *mediaFileRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.MediaFileCursor, error) { - sq := r.selectMediaFile(ctx, options...) - cursor, err := queryWithStableResults[dbMediaFile](ctx, r.sqlRepository, sq) +func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.MediaFileCursor, error) { + sq := r.selectMediaFile(options...) + cursor, err := queryWithStableResults[dbMediaFile](r.sqlRepository, sq) if err != nil { return nil, err } return wrapMediaFileCursor(cursor), nil } -// getAllIDs returns the IDs of GetAll's row set, skipping its wide column projection. -func (r *mediaFileRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - sq := r.applyLibraryFilter(ctx, r.newSelect(ctx, options...).Columns("media_file.id")) - if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(ctx, sq, "media_file.id") - } - ids := []string{} - err := r.queryAllSlice(ctx, sq, &ids) - return ids, err -} - -func (r *mediaFileRepository) GetAlbumIDsByFolder(ctx context.Context, lib model.Library, folderIDs ...string) ([]string, error) { - ids := []string{} - for chunk := range slices.Chunk(folderIDs, 200) { - // A folder's own cover also covers albums whose tracks sit in its disc subfolders. - inFolders := Select("f.id").From("folder f").Where(And{ - Eq{"f.library_id": lib.ID}, - Eq{"f.missing": false}, - Or{Eq{"f.id": chunk}, Eq{"f.parent_id": chunk}}, - }) - sq := Select("distinct album_id").From("media_file"). - Where(And{Eq{"missing": false}, ConcatExpr("folder_id IN (", inFolders, ")")}) - var chunkIDs []string - if err := r.queryAllSlice(ctx, sq, &chunkIDs); err != nil { - return nil, err - } - ids = append(ids, chunkIDs...) - } - return ids, nil -} - -// GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass. -func (r *mediaFileRepository) GetCursorWithArtwork(ctx context.Context, options ...model.QueryOptions) (model.MediaFileCursor, error) { - ids, err := r.getAllIDs(ctx, options...) - if err != nil { - return nil, err - } - opts := chunkOptions(options, "media_file.id") - return model.MediaFileCursor(streamByIDs(ids, func(chunk []string) (model.MediaFiles, error) { - return r.GetAll(ctx, opts(chunk)) - })), nil -} - // FindByPaths finds media files by their paths. // The paths can be library-qualified (format: "libraryID:path") or unqualified ("path"). // Library-qualified paths search within the specified library, while unqualified paths // search across all libraries for backward compatibility. -func (r *mediaFileRepository) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) { - // One IN list per library instead of one OR term per path: SQLite abandons the - // path index at just two OR-ed equality terms and scans the whole table. - byLibrary := map[int][]string{} - var unqualified []string +func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) { + query := Or{} for _, path := range paths { - // A numeric prefix is ambiguous: "1:foo.mp3" qualifies a library, but "1999: A Life/01.mp3" - // is a plain path. Search both ways rather than guessing. - if id, rest, ok := strings.Cut(path, ":"); ok { - if libraryID, err := strconv.Atoi(id); err == nil { - byLibrary[libraryID] = append(byLibrary[libraryID], rest) + parts := strings.SplitN(path, ":", 2) + if len(parts) == 2 { + // Library-qualified path: "libraryID:path" + libraryID, err := strconv.Atoi(parts[0]) + if err != nil { + // Invalid format, skip + continue } + relativePath := parts[1] + query = append(query, And{ + Eq{"path collate nocase": relativePath}, + Eq{"library_id": libraryID}, + }) + } else { + // Unqualified path: search across all libraries + query = append(query, Eq{"path collate nocase": path}) } - unqualified = append(unqualified, path) - } - - query := Or{} - for _, libraryID := range slices.Sorted(maps.Keys(byLibrary)) { - query = append(query, And{ - Eq{"path collate nocase": byLibrary[libraryID]}, - Eq{"library_id": libraryID}, - }) - } - if len(unqualified) > 0 { - query = append(query, Eq{"path collate nocase": unqualified}) } if len(query) == 0 { return model.MediaFiles{}, nil } - sel := r.applyLibraryFilter(ctx, r.newSelect(ctx).Columns("*").Where(query)) + sel := r.newSelect().Columns("*").Where(query) var res dbMediaFiles - if err := r.queryAll(ctx, sel, &res); err != nil { + if err := r.queryAll(sel, &res); err != nil { return nil, err } return res.toModels(), nil } -func (r *mediaFileRepository) Delete(ctx context.Context, id string) error { - return r.delete(ctx, Eq{"id": id}) +func (r *mediaFileRepository) Delete(id string) error { + return r.delete(Eq{"id": id}) } -func (r *mediaFileRepository) ReassignReferences(ctx context.Context, prevID, newID string) error { - if err := r.ReassignAnnotation(ctx, prevID, newID); err != nil { - return fmt.Errorf("reassigning annotations: %w", err) - } - if err := r.reassignBookmark(ctx, prevID, newID); err != nil { - return fmt.Errorf("reassigning bookmarks: %w", err) - } - upd := Update("playlist_tracks").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) - if _, err := r.executeSQL(ctx, upd); err != nil { - return fmt.Errorf("reassigning playlist tracks: %w", err) - } - upd = Update("scrobbles").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) - if _, err := r.executeSQL(ctx, upd); err != nil { - return fmt.Errorf("reassigning scrobbles: %w", err) - } - // OR IGNORE: scrobble_buffer is unique on (user_id, service, media_file_id, play_time) - buf := Expr("update or ignore scrobble_buffer set media_file_id = ? where media_file_id = ?", newID, prevID) - if _, err := r.executeSQL(ctx, buf); err != nil { - return fmt.Errorf("reassigning buffered scrobbles: %w", err) - } - return nil -} - -func (r *mediaFileRepository) DeleteAllMissing(ctx context.Context) (int64, error) { - user := loggedUser(ctx) +func (r *mediaFileRepository) DeleteAllMissing() (int64, error) { + user := loggedUser(r.ctx) if !user.IsAdmin { return 0, rest.ErrPermissionDenied } del := Delete(r.tableName).Where(Eq{"missing": true}) - return r.executeSQL(ctx, del) + return r.executeSQL(del) } -func (r *mediaFileRepository) DeleteMissing(ctx context.Context, ids []string) error { - user := loggedUser(ctx) +func (r *mediaFileRepository) DeleteMissing(ids []string) error { + user := loggedUser(r.ctx) if !user.IsAdmin { return rest.ErrPermissionDenied } - return r.delete(ctx, + return r.delete( And{ Eq{"missing": true}, Eq{"id": ids}, @@ -452,24 +306,24 @@ func (r *mediaFileRepository) DeleteMissing(ctx context.Context, ids []string) e ) } -func (r *mediaFileRepository) MarkMissing(ctx context.Context, missing bool, mfs ...*model.MediaFile) error { +func (r *mediaFileRepository) MarkMissing(missing bool, mfs ...*model.MediaFile) error { ids := slice.SeqFunc(mfs, func(m *model.MediaFile) string { return m.ID }) for chunk := range slice.CollectChunks(ids, 200) { upd := Update(r.tableName). Set("missing", missing). Set("updated_at", time.Now()). Where(Eq{"id": chunk}) - c, err := r.executeSQL(ctx, upd) + c, err := r.executeSQL(upd) if err != nil || c == 0 { - log.Error(ctx, "Error setting mediafile missing flag", "ids", chunk, err) + log.Error(r.ctx, "Error setting mediafile missing flag", "ids", chunk, err) return err } - log.Debug(ctx, "Marked missing mediafiles", "total", c, "ids", chunk) + log.Debug(r.ctx, "Marked missing mediafiles", "total", c, "ids", chunk) } return nil } -func (r *mediaFileRepository) MarkMissingByFolder(ctx context.Context, missing bool, folderIDs ...string) error { +func (r *mediaFileRepository) MarkMissingByFolder(missing bool, folderIDs ...string) error { for chunk := range slices.Chunk(folderIDs, 200) { upd := Update(r.tableName). Set("missing", missing). @@ -478,12 +332,12 @@ func (r *mediaFileRepository) MarkMissingByFolder(ctx context.Context, missing b Eq{"folder_id": chunk}, Eq{"missing": !missing}, }) - c, err := r.executeSQL(ctx, upd) + c, err := r.executeSQL(upd) if err != nil { - log.Error(ctx, "Error setting mediafile missing flag", "folderIDs", chunk, err) + log.Error(r.ctx, "Error setting mediafile missing flag", "folderIDs", chunk, err) return err } - log.Debug(ctx, "Marked missing mediafiles from missing folders", "total", c, "folders", chunk) + log.Debug(r.ctx, "Marked missing mediafiles from missing folders", "total", c, "folders", chunk) } return nil } @@ -491,8 +345,8 @@ func (r *mediaFileRepository) MarkMissingByFolder(ctx context.Context, missing b // GetMissingAndMatching returns all mediafiles that are missing and their potential matches (comparing PIDs) // that were added/updated after the last scan started. The result is ordered by PID. // It does not need to load bookmarks, annotations and participants, as they are not used by the scanner. -func (r *mediaFileRepository) GetMissingAndMatching(ctx context.Context, libId int) (model.MediaFileCursor, error) { - subQ := r.newSelect(ctx).Columns("pid"). +func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) { + subQ := r.newSelect().Columns("pid"). Where(And{ Eq{"media_file.missing": true}, Eq{"library_id": libId}, @@ -501,7 +355,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(ctx context.Context, libId i if err != nil { return nil, err } - sel := r.newSelect(ctx).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). + sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id"). Where("pid in ("+subQText+")", subQArgs...). Where(Or{ @@ -509,7 +363,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(ctx context.Context, libId i ConcatExpr("media_file.created_at > library.last_scan_started_at"), }). OrderBy("pid") - cursor, err := queryWithStableResults[dbMediaFile](ctx, r.sqlRepository, sel) + cursor, err := queryWithStableResults[dbMediaFile](r.sqlRepository, sel) if err != nil { return nil, err } @@ -517,13 +371,23 @@ func (r *mediaFileRepository) GetMissingAndMatching(ctx context.Context, libId i } func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor { - return model.MediaFileCursor(wrapCursor(cursor, func(m dbMediaFile) *model.MediaFile { return m.MediaFile })) + return func(yield func(model.MediaFile, error) bool) { + for m, err := range cursor { + if m.MediaFile == nil { + yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile (%v): %w", m, err)) + return + } + if !yield(*m.MediaFile, err) || err != nil { + return + } + } + } } // FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries // It uses a lightweight query without annotation/bookmark joins since those are not needed for matching -func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(ctx context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { - sel := r.newSelect(ctx).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). +func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { + sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id"). Where(And{ NotEq{"media_file.library_id": missing.LibraryID}, @@ -535,7 +399,7 @@ func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(ctx context.Context, m }).OrderBy("media_file.created_at DESC") var res dbMediaFiles - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) if err != nil { return nil, err } @@ -544,8 +408,8 @@ func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(ctx context.Context, m // FindRecentFilesByProperties finds recently added files by intrinsic properties in other libraries // It uses a lightweight query without annotation/bookmark joins since those are not needed for matching -func (r *mediaFileRepository) FindRecentFilesByProperties(ctx context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { - sel := r.newSelect(ctx).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). +func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { + sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id"). Where(And{ NotEq{"media_file.library_id": missing.LibraryID}, @@ -561,7 +425,7 @@ func (r *mediaFileRepository) FindRecentFilesByProperties(ctx context.Context, m }).OrderBy("media_file.created_at DESC") var res dbMediaFiles - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) if err != nil { return nil, err } @@ -574,49 +438,38 @@ var mediaFileSearchConfig = searchConfig{ MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"}, } -func (r *mediaFileRepository) MatchesCriteria(ctx context.Context, id string, c criteria.Criteria) (bool, error) { - usr := loggedUser(ctx) - rulesSQL := newSmartPlaylistCriteria(c, withSmartPlaylistOwner(*usr)) - cond, err := rulesSQL.where() - if err != nil { - return false, err - } - sq := Select("count(*) as count").From("media_file") - sq = rulesSQL.applyExpressionJoins(sq, usr.ID) - sq = sq.Where(And{Eq{"media_file.id": id}, cond}) - var res struct{ Count int64 } - if err := r.queryOne(ctx, sq, &res); err != nil { - return false, err - } - return res.Count > 0, nil -} - -func (r *mediaFileRepository) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.MediaFiles, error) { +func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) { var opts model.QueryOptions if len(options) > 0 { opts = options[0] } var res dbMediaFiles - err := r.doSearch(ctx, r.selectMediaFile(ctx, options...), q, &res, mediaFileSearchConfig, opts) + err := r.doSearch(r.selectMediaFile(options...), q, &res, mediaFileSearchConfig, opts) if err != nil { return nil, fmt.Errorf("searching media_file %q: %w", q, err) } - mfs := res.toModels() - r.hydrateArtwork(ctx, mfs) - return mfs, nil + return res.toModels(), nil } -func (r *mediaFileRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *mediaFileRepository) Read(ctx context.Context, id string) (*model.MediaFile, error) { - return r.Get(ctx, id) +func (r *mediaFileRepository) Read(id string) (any, error) { + return r.Get(id) } -func (r *mediaFileRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.MediaFile, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *mediaFileRepository) EntityName() string { + return "mediafile" +} + +func (r *mediaFileRepository) NewInstance() any { + return &model.MediaFile{} } var _ model.MediaFileRepository = (*mediaFileRepository)(nil) -var _ rest.Repository[model.MediaFile] = (*mediaFileRepository)(nil) +var _ model.ResourceRepository = (*mediaFileRepository)(nil) diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 14590090c..464d88288 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "reflect" "time" "github.com/Masterminds/squirrel" @@ -13,10 +12,8 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -24,123 +21,27 @@ import ( var _ = Describe("MediaRepository", func() { var mr model.MediaFileRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid"}) - mr = NewMediaFileRepository(GetDBXBuilder()) - }) - - Describe("GetAlbumIDsByFolder", func() { - var lib model.Library - var albumRoot, disc1, sibling *model.Folder - - BeforeEach(func() { - ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) - libPtr, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - lib = *libPtr - - folderRepo := newFolderRepository(GetDBXBuilder()) - albumRoot = model.NewFolder(lib, "ByFolder/Album") - disc1 = model.NewFolder(lib, "ByFolder/Album/CD1") - sibling = model.NewFolder(lib, "ByFolder/Other") - for _, f := range []*model.Folder{albumRoot, disc1, sibling} { - Expect(folderRepo.Put(ctx, f)).To(Succeed()) - } - // Tracks live in the disc subfolder; the sibling album is the negative control. - Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-1", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/1.mp3"})).To(Succeed()) - Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-2", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/2.mp3"})).To(Succeed()) - Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-3", LibraryID: 1, AlbumID: "fol-al-2", FolderID: sibling.ID, Path: "t/3.mp3"})).To(Succeed()) - Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-4", LibraryID: 1, AlbumID: "fol-al-3", FolderID: disc1.ID, Path: "t/4.mp3", Missing: true})).To(Succeed()) - DeferCleanup(func() { - _, _ = GetDBXBuilder().NewQuery("DELETE FROM media_file WHERE id LIKE 'fol-mf-%'").Execute() - _, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'ByFolder%' OR name = 'ByFolder'").Execute() - }) - }) - - It("returns the distinct album IDs of non-missing tracks in the folder", func() { - ids, err := mr.GetAlbumIDsByFolder(ctx, lib, disc1.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf("fol-al-1")) - }) - - It("also matches albums whose tracks are in a direct child of the folder", func() { - // A cover in the album root must reach the album whose tracks sit in CD1 - ids, err := mr.GetAlbumIDsByFolder(ctx, lib, albumRoot.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf("fol-al-1")) - }) - - It("does not match albums outside the folder", func() { - ids, err := mr.GetAlbumIDsByFolder(ctx, lib, albumRoot.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).ToNot(ContainElement("fol-al-2")) - }) - }) - - Describe("GetCursor", func() { - It("yields the same media files as GetAll", func() { - opts := model.QueryOptions{Sort: "title"} - want, err := mr.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(mr.GetCursor(ctx, opts))).To(Equal([]model.MediaFile(want))) - }) - - It("honors Max/Offset like GetAll", func() { - opts := model.QueryOptions{Sort: "title", Max: 2, Offset: 1} - want, err := mr.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(mr.GetCursor(ctx, opts))).To(Equal([]model.MediaFile(want))) - }) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + mr = NewMediaFileRepository(ctx, GetDBXBuilder()) }) It("gets mediafile from the DB", func() { - actual, err := mr.Get(ctx, "1004") + actual, err := mr.Get("1004") Expect(err).ToNot(HaveOccurred()) actual.CreatedAt = time.Time{} Expect(actual).To(Equal(&songAntenna)) }) It("returns ErrNotFound", func() { - _, err := mr.Get(ctx, "56") + _, err := mr.Get("56") Expect(err).To(MatchError(model.ErrNotFound)) }) It("counts the number of mediafiles in the DB", func() { - Expect(mr.CountAll(ctx)).To(Equal(int64(13))) - }) - - Describe("CountAll annotation-join gating", func() { - var adminRepo model.MediaFileRepository - var adminCtx context.Context - - BeforeEach(func() { - adminCtx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true}) - adminRepo = NewMediaFileRepository(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(adminCtx, 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(adminCtx, 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(adminCtx)).To(Equal(int64(13))) - }) + Expect(mr.CountAll()).To(Equal(int64(13))) }) Describe("CountBySuffix", func() { @@ -152,21 +53,21 @@ var _ = Describe("MediaRepository", func() { flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "test/file2.flac"} flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "test/file.FLAC"} - Expect(mr.Put(ctx, &mp3File)).To(Succeed()) - Expect(mr.Put(ctx, &flacFile1)).To(Succeed()) - Expect(mr.Put(ctx, &flacFile2)).To(Succeed()) - Expect(mr.Put(ctx, &flacUpperFile)).To(Succeed()) + Expect(mr.Put(&mp3File)).To(Succeed()) + Expect(mr.Put(&flacFile1)).To(Succeed()) + Expect(mr.Put(&flacFile2)).To(Succeed()) + Expect(mr.Put(&flacUpperFile)).To(Succeed()) }) AfterEach(func() { - _ = mr.Delete(ctx, mp3File.ID) - _ = mr.Delete(ctx, flacFile1.ID) - _ = mr.Delete(ctx, flacFile2.ID) - _ = mr.Delete(ctx, flacUpperFile.ID) + _ = mr.Delete(mp3File.ID) + _ = mr.Delete(flacFile1.ID) + _ = mr.Delete(flacFile2.ID) + _ = mr.Delete(flacUpperFile.ID) }) It("counts media files grouped by suffix with lowercase normalization", func() { - counts, err := mr.CountBySuffix(ctx) + counts, err := mr.CountBySuffix() Expect(err).ToNot(HaveOccurred()) // Should have lowercase keys only @@ -183,7 +84,7 @@ var _ = Describe("MediaRepository", func() { It("returns songs ordered by lyrics with a specific title/artist", func() { // attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items - results, err := mr.GetAll(ctx, model.QueryOptions{ + results, err := mr.GetAll(model.QueryOptions{ Sort: "lyrics, updated_at", Order: "desc", Filters: squirrel.And{ @@ -205,113 +106,17 @@ var _ = Describe("MediaRepository", func() { } }) - Describe("GetRandom", func() { - It("returns the requested number of distinct, fully-hydrated media files", func() { - results, err := mr.GetRandom(ctx, 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(ctx) - 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(ctx, model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(13)) - }) - - It("honors filters", func() { - results, err := mr.GetRandom(ctx, 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(ctx, 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(ctx, 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(ctx, 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(ctx, 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) newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "test/created-at-zero.mp3"} - Expect(mr.Put(ctx, &newFile)).To(Succeed()) + Expect(mr.Put(&newFile)).To(Succeed()) - retrieved, err := mr.Get(ctx, newFile.ID) + retrieved, err := mr.Get(newFile.ID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.CreatedAt).To(BeTemporally(">", before)) - _ = mr.Delete(ctx, newFile.ID) + _ = mr.Delete(newFile.ID) }) It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() { @@ -322,13 +127,13 @@ var _ = Describe("MediaRepository", func() { Path: "test/created-at-preserved.mp3", CreatedAt: originalTime, } - Expect(mr.Put(ctx, &newFile)).To(Succeed()) + Expect(mr.Put(&newFile)).To(Succeed()) - retrieved, err := mr.Get(ctx, newFile.ID) + retrieved, err := mr.Get(newFile.ID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second)) - _ = mr.Delete(ctx, newFile.ID) + _ = mr.Delete(newFile.ID) }) It("does not reset CreatedAt when updating an existing file", func() { @@ -341,7 +146,7 @@ var _ = Describe("MediaRepository", func() { Title: "Original Title", CreatedAt: originalTime, } - Expect(mr.Put(ctx, &newFile)).To(Succeed()) + Expect(mr.Put(&newFile)).To(Succeed()) // Update the file with a new title but zero CreatedAt updatedFile := model.MediaFile{ @@ -351,66 +156,66 @@ var _ = Describe("MediaRepository", func() { Title: "Updated Title", // CreatedAt is zero - should NOT overwrite the stored value } - Expect(mr.Put(ctx, &updatedFile)).To(Succeed()) + Expect(mr.Put(&updatedFile)).To(Succeed()) - retrieved, err := mr.Get(ctx, fileID) + retrieved, err := mr.Get(fileID) Expect(err).ToNot(HaveOccurred()) Expect(retrieved.Title).To(Equal("Updated Title")) // CreatedAt should still be the original time (not reset) Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second)) - _ = mr.Delete(ctx, fileID) + _ = mr.Delete(fileID) }) }) It("checks existence of mediafiles in the DB", func() { - Expect(mr.Exists(ctx, songAntenna.ID)).To(BeTrue()) - Expect(mr.Exists(ctx, "666")).To(BeFalse()) + Expect(mr.Exists(songAntenna.ID)).To(BeTrue()) + Expect(mr.Exists("666")).To(BeFalse()) }) It("delete tracks by id", func() { newID := id.NewRandom() - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed()) - Expect(mr.Delete(ctx, newID)).To(Succeed()) + Expect(mr.Delete(newID)).To(Succeed()) - _, err := mr.Get(ctx, newID) + _, err := mr.Get(newID) Expect(err).To(MatchError(model.ErrNotFound)) }) It("deletes all missing files", func() { new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} - Expect(mr.Put(ctx, &new1)).To(Succeed()) - Expect(mr.Put(ctx, &new2)).To(Succeed()) - Expect(mr.MarkMissing(ctx, true, &new1, &new2)).To(Succeed()) + Expect(mr.Put(&new1)).To(Succeed()) + Expect(mr.Put(&new2)).To(Succeed()) + Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed()) adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) - adminRepo := NewMediaFileRepository(GetDBXBuilder()) + adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder()) // Ensure the files are marked as missing and we have 2 of them - count, err := adminRepo.CountAll(adminCtx, model.QueryOptions{Filters: squirrel.Eq{"missing": true}}) + count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}}) Expect(count).To(BeNumerically("==", 2)) Expect(err).ToNot(HaveOccurred()) - count, err = adminRepo.DeleteAllMissing(adminCtx) + count, err = adminRepo.DeleteAllMissing() Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeNumerically("==", 2)) - _, err = mr.Get(ctx, new1.ID) + _, err = mr.Get(new1.ID) Expect(err).To(MatchError(model.ErrNotFound)) - _, err = mr.Get(ctx, new2.ID) + _, err = mr.Get(new2.ID) Expect(err).To(MatchError(model.ErrNotFound)) }) Context("Annotations", func() { It("increments play count when the tracks does not have annotations", func() { id := "incplay.firsttime" - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) playDate := time.Now() - Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) + Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) - mf, err := mr.Get(ctx, id) + mf, err := mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) @@ -426,85 +231,85 @@ var _ = Describe("MediaRepository", func() { It("returns 0 when no ratings exist", func() { newID := id.NewRandom() - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/no-rating.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/no-rating.mp3"})).To(Succeed()) - mf, err := mr.Get(ctx, newID) + mf, err := mr.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(0.0)) - _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) + _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) It("returns the user's rating as average when only one user rated", func() { newID := id.NewRandom() - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/single-rating.mp3"})).To(Succeed()) - Expect(mr.SetRating(ctx, 5, newID)).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/single-rating.mp3"})).To(Succeed()) + Expect(mr.SetRating(5, newID)).To(Succeed()) - mf, err := mr.Get(ctx, newID) + mf, err := mr.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(5.0)) - _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) + _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) It("calculates average across multiple users", func() { newID := id.NewRandom() - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/multi-rating.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/multi-rating.mp3"})).To(Succeed()) - Expect(mr.SetRating(ctx, 3, newID)).To(Succeed()) + Expect(mr.SetRating(3, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - user2Repo := NewMediaFileRepository(GetDBXBuilder()) - Expect(user2Repo.SetRating(user2Ctx, 5, newID)).To(Succeed()) + user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder()) + Expect(user2Repo.SetRating(5, newID)).To(Succeed()) - mf, err := mr.Get(ctx, newID) + mf, err := mr.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(4.0)) - _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) + _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) It("excludes zero ratings from average calculation", func() { newID := id.NewRandom() - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/zero-excluded.mp3"})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "test/zero-excluded.mp3"})).To(Succeed()) - Expect(mr.SetRating(ctx, 4, newID)).To(Succeed()) + Expect(mr.SetRating(4, newID)).To(Succeed()) user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - user2Repo := NewMediaFileRepository(GetDBXBuilder()) - Expect(user2Repo.SetRating(user2Ctx, 0, newID)).To(Succeed()) + user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder()) + Expect(user2Repo.SetRating(0, newID)).To(Succeed()) - mf, err := mr.Get(ctx, newID) + mf, err := mr.Get(newID) Expect(err).ToNot(HaveOccurred()) Expect(mf.AverageRating).To(Equal(4.0)) - _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) + _, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) + _, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) }) }) It("preserves play date if and only if provided date is older", func() { id := "incplay.playdate" - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) playDate := time.Now() - Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) - mf, err := mr.Get(ctx, id) + Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) + mf, err := mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) Expect(mf.PlayCount).To(Equal(int64(1))) playDateLate := playDate.AddDate(0, 0, 1) - Expect(mr.IncPlayCount(ctx, id, playDateLate)).To(BeNil()) - mf, err = mr.Get(ctx, id) + Expect(mr.IncPlayCount(id, playDateLate)).To(BeNil()) + mf, err = mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix())) Expect(mf.PlayCount).To(Equal(int64(2))) playDateEarly := playDate.AddDate(0, 0, -1) - Expect(mr.IncPlayCount(ctx, id, playDateEarly)).To(BeNil()) - mf, err = mr.Get(ctx, id) + Expect(mr.IncPlayCount(id, playDateEarly)).To(BeNil()) + mf, err = mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix())) Expect(mf.PlayCount).To(Equal(int64(3))) @@ -512,12 +317,12 @@ var _ = Describe("MediaRepository", func() { It("increments play count on newly starred items", func() { id := "star.incplay" - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) - Expect(mr.SetStar(ctx, true, id)).To(BeNil()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) + Expect(mr.SetStar(true, id)).To(BeNil()) playDate := time.Now() - Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) + Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) - mf, err := mr.Get(ctx, id) + mf, err := mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) @@ -556,7 +361,7 @@ var _ = Describe("MediaRepository", func() { // Insert test data first for i := range testMediaFiles { - Expect(mr.Put(ctx, &testMediaFiles[i])).To(Succeed()) + Expect(mr.Put(&testMediaFiles[i])).To(Succeed()) } // Then manually update timestamps using direct SQL to bypass the repository logic @@ -598,7 +403,7 @@ var _ = Describe("MediaRepository", func() { AfterEach(func() { // Clean up test data for _, mf := range testMediaFiles { - _ = mr.Delete(ctx, mf.ID) + _ = mr.Delete(mf.ID) } }) @@ -608,12 +413,14 @@ var _ = Describe("MediaRepository", func() { BeforeEach(func() { conf.Server.RecentlyAddedByModTime = false // Create repository AFTER setting config - testRepo = NewMediaFileRepository(GetDBXBuilder()) + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + testRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) }) It("sorts by created_at", func() { // Get results sorted by recently_added (should use created_at) - results, err := testRepo.GetAll(ctx, model.QueryOptions{ + results, err := testRepo.GetAll(model.QueryOptions{ Sort: "recently_added", Order: "desc", Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, @@ -629,7 +436,7 @@ var _ = Describe("MediaRepository", func() { It("sorts in ascending order when specified", func() { // Get results sorted by recently_added in ascending order - results, err := testRepo.GetAll(ctx, model.QueryOptions{ + results, err := testRepo.GetAll(model.QueryOptions{ Sort: "recently_added", Order: "asc", Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, @@ -650,12 +457,14 @@ var _ = Describe("MediaRepository", func() { BeforeEach(func() { conf.Server.RecentlyAddedByModTime = true // Create repository AFTER setting config - testRepo = NewMediaFileRepository(GetDBXBuilder()) + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + testRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) }) It("sorts by updated_at", func() { // Get results sorted by recently_added (should use updated_at) - results, err := testRepo.GetAll(ctx, model.QueryOptions{ + results, err := testRepo.GetAll(model.QueryOptions{ Sort: "recently_added", Order: "desc", Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, @@ -670,34 +479,6 @@ 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(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(ctx, 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())) - }) - }) }) @@ -706,20 +487,20 @@ var _ = Describe("MediaRepository", func() { BeforeEach(func() { mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "test/no-annotation.mp3", Title: "No Annotation"} - Expect(mr.Put(ctx, &mfWithoutAnnotation)).To(Succeed()) + Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed()) }) AfterEach(func() { - _ = mr.Delete(ctx, mfWithoutAnnotation.ID) + _ = mr.Delete(mfWithoutAnnotation.ID) }) Describe("starred", func() { It("false includes items without annotations", func() { - res, err := mr.ReadAll(ctx, rest.QueryOptions{ + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ Filters: map[string]any{"starred": "false"}, }) Expect(err).ToNot(HaveOccurred()) - files := res + files := res.(model.MediaFiles) var found bool for _, f := range files { @@ -732,51 +513,23 @@ var _ = Describe("MediaRepository", func() { }) It("true excludes items without annotations", func() { - res, err := mr.ReadAll(ctx, rest.QueryOptions{ + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ Filters: map[string]any{"starred": "true"}, }) Expect(err).ToNot(HaveOccurred()) - files := res + files := res.(model.MediaFiles) for _, f := range files { Expect(f.ID).ToNot(Equal(mfWithoutAnnotation.ID)) } }) }) - - Describe("path", func() { - It("matches files whose path starts with the given prefix", func() { - res, err := mr.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"path": "test/"}, - }) - Expect(err).ToNot(HaveOccurred()) - files := res - - 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.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"path": "no-such-prefix/"}, - }) - Expect(err).ToNot(HaveOccurred()) - files := res - Expect(files).To(BeEmpty()) - }) - }) }) Describe("Search", func() { Context("text search", func() { It("finds media files by title", func() { - results, err := mr.Search(ctx, "Antenna", model.QueryOptions{Max: 10}) + results, err := mr.Search("Antenna", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2 for _, result := range results { @@ -785,7 +538,7 @@ var _ = Describe("MediaRepository", func() { }) It("finds media files case insensitively", func() { - results, err := mr.Search(ctx, "antenna", model.QueryOptions{Max: 10}) + results, err := mr.Search("antenna", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) for _, result := range results { @@ -794,7 +547,7 @@ var _ = Describe("MediaRepository", func() { }) It("returns empty result when no matches found", func() { - results, err := mr.Search(ctx, "nonexistent", model.QueryOptions{Max: 10}) + results, err := mr.Search("nonexistent", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -817,17 +570,17 @@ var _ = Describe("MediaRepository", func() { } // Insert the test media file into the database - err := mr.Put(ctx, &mediaFileWithMBID) + err := mr.Put(&mediaFileWithMBID) Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { // Clean up test data using direct SQL - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID})) + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID})) }) It("finds media file by mbz_recording_id", func() { - results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10}) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("test-mbid-mediafile")) @@ -835,7 +588,7 @@ var _ = Describe("MediaRepository", func() { }) It("finds media file by mbz_release_track_id", func() { - results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10}) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("test-mbid-mediafile")) @@ -843,7 +596,7 @@ var _ = Describe("MediaRepository", func() { }) It("returns empty result when MBID is not found", func() { - results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10}) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) @@ -859,161 +612,18 @@ var _ = Describe("MediaRepository", func() { Missing: true, } - err := mr.Put(ctx, &missingMediaFile) + err := mr.Put(&missingMediaFile) Expect(err).ToNot(HaveOccurred()) // Search never returns missing media files (hardcoded behavior) - results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10}) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) // Clean up - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID})) + _, _ = 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(ctx, "", 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(ctx, "", model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - quoted, err := mr.Search(ctx, `""`, 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(ctx, "", 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(ctx, "", 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(ctx, "", model.QueryOptions{Max: 10, Offset: 100000}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - }) - }) - - Describe("ReassignReferences", func() { - var prev, next model.MediaFile - var pr model.PlaylistRepository - var pls model.Playlist - - BeforeEach(func() { - ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) - pr = NewPlaylistRepository(GetDBXBuilder()) - prev = model.MediaFile{ID: "reassign-prev", LibraryID: 1, Path: "reassign/prev.mp3", Title: "Prev"} - next = model.MediaFile{ID: "reassign-next", LibraryID: 1, Path: "reassign/next.mp3", Title: "Next"} - Expect(mr.Put(ctx, &prev)).To(Succeed()) - Expect(mr.Put(ctx, &next)).To(Succeed()) - pls = model.Playlist{Name: "Reassign", OwnerID: "userid"} - pls.AddMediaFilesByID([]string{prev.ID}) - Expect(pr.Put(ctx, &pls)).To(Succeed()) - }) - - AfterEach(func() { - _ = pr.Delete(ctx, pls.ID) - _ = mr.Delete(ctx, prev.ID) - _ = mr.Delete(ctx, next.ID) - _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) - _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("bookmark").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) - _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("scrobbles").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) - _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("scrobble_buffer").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) - }) - - It("moves annotations, bookmarks and playlist entries onto the new id", func() { - Expect(mr.SetRating(ctx, 5, prev.ID)).To(Succeed()) - Expect(mr.AddBookmark(ctx, prev.ID, "here", 42)).To(Succeed()) - - Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := mr.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Rating).To(Equal(5)) - - bookmarks, err := mr.GetBookmarks(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(bookmarks).To(ContainElement(HaveField("Item.ID", next.ID))) - - withTracks, err := pr.GetWithTracks(ctx, pls.ID, false, false) - Expect(err).ToNot(HaveOccurred()) - Expect(withTracks.Tracks).To(HaveLen(1)) - Expect(withTracks.Tracks[0].MediaFileID).To(Equal(next.ID)) - }) - - It("moves scrobbles and buffered scrobbles onto the new id", func() { - ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) - scrobbles := NewScrobbleRepository(GetDBXBuilder()) - buffer := NewScrobbleBufferRepository(GetDBXBuilder()) - Expect(scrobbles.RecordScrobble(ctx, prev.ID, time.Now())).To(Succeed()) - Expect(buffer.Enqueue(ctx, "lastfm", "userid", prev.ID, time.Now())).To(Succeed()) - - Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) - Expect(mr.Delete(ctx, prev.ID)).To(Succeed()) - - all, err := scrobbles.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - mine := slice.Map(slice.Filter(all, func(sc model.Scrobble) bool { - return sc.MediaFileID == prev.ID || sc.MediaFileID == next.ID - }), func(sc model.Scrobble) string { return sc.MediaFileID }) - Expect(mine).To(ConsistOf(next.ID)) - - entry, err := buffer.Next(ctx, "lastfm", "userid") - Expect(err).ToNot(HaveOccurred()) - Expect(entry).ToNot(BeNil()) - Expect(entry.MediaFile.ID).To(Equal(next.ID)) - }) - - It("recomputes the average rating after merging another user's annotation", func() { - otherCtx := request.WithUser(ctx, model.User{ID: "2222"}) - other := NewMediaFileRepository(GetDBXBuilder()) - Expect(mr.SetRating(ctx, 5, next.ID)).To(Succeed()) - Expect(other.SetRating(otherCtx, 3, prev.ID)).To(Succeed()) - - Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := mr.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.AverageRating).To(Equal(4.0)) - }) - - It("keeps the new id's own annotation and bookmark when both exist", func() { - Expect(mr.SetRating(ctx, 5, prev.ID)).To(Succeed()) - Expect(mr.SetRating(ctx, 1, next.ID)).To(Succeed()) - Expect(mr.AddBookmark(ctx, prev.ID, "prev", 42)).To(Succeed()) - Expect(mr.AddBookmark(ctx, next.ID, "next", 7)).To(Succeed()) - - Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := mr.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Rating).To(Equal(1)) - bookmarks, err := mr.GetBookmarks(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(bookmarks).To(ContainElement(SatisfyAll(HaveField("Item.ID", next.ID), HaveField("Comment", "next")))) - }) }) Describe("FindByPaths", func() { @@ -1028,43 +638,20 @@ var _ = Describe("MediaRepository", func() { {ID: "findpath-3", LibraryID: 1, Path: "plex/02 - ACROSS.flac", Title: "Fullwidth"}, // French diacritic: è (U+00E8, can decompose to e + combining grave) {ID: "findpath-4", LibraryID: 1, Path: "artist/Michèle/song.mp3", Title: "French"}, - {ID: "findpath-5", LibraryID: 1, Path: "Bach: Goldberg Variations/01.mp3", Title: "Colon"}, - {ID: "findpath-6", LibraryID: 1, Path: "1999: A Different Life/01.mp3", Title: "Numeric colon"}, } for _, mf := range testFiles { - Expect(mr.Put(ctx, &mf)).To(Succeed()) + Expect(mr.Put(&mf)).To(Succeed()) } }) AfterEach(func() { for _, mf := range testFiles { - _ = mr.Delete(ctx, mf.ID) + _ = mr.Delete(mf.ID) } }) - It("treats a path whose prefix is not a library id as unqualified", func() { - results, err := mr.FindByPaths(ctx, []string{"Bach: Goldberg Variations/01.mp3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("findpath-5")) - }) - - It("finds a plain path whose colon prefix looks like a library id", func() { - results, err := mr.FindByPaths(ctx, []string{"1999: A Different Life/01.mp3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("findpath-6")) - }) - - It("splits only the first colon of a library-qualified path", func() { - results, err := mr.FindByPaths(ctx, []string{"1:Bach: Goldberg Variations/01.mp3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("findpath-5")) - }) - It("finds files by exact path", func() { - results, err := mr.FindByPaths(ctx, []string{"1:artist/Album/track.mp3"}) + results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-1")) @@ -1072,7 +659,7 @@ var _ = Describe("MediaRepository", func() { It("finds files case-insensitively for ASCII characters (NOCASE)", func() { // SQLite's COLLATE NOCASE handles ASCII case-insensitivity - results, err := mr.FindByPaths(ctx, []string{"1:ARTIST/ALBUM/TRACK.MP3"}) + results, err := mr.FindByPaths([]string{"1:ARTIST/ALBUM/TRACK.MP3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-1")) @@ -1081,20 +668,20 @@ var _ = Describe("MediaRepository", func() { It("finds fullwidth characters only with exact case match (SQLite NOCASE limitation)", func() { // SQLite's NOCASE does NOT handle fullwidth uppercase/lowercase equivalence // The DB has fullwidth uppercase ACROSS, searching with exact match should work - results, err := mr.FindByPaths(ctx, []string{"1:plex/02 - ACROSS.flac"}) + results, err := mr.FindByPaths([]string{"1:plex/02 - ACROSS.flac"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("findpath-3")) // Searching with fullwidth lowercase across should NOT match // (this is the SQLite limitation that requires exact matching for non-ASCII) - results, err = mr.FindByPaths(ctx, []string{"1:plex/02 - across.flac"}) + results, err = mr.FindByPaths([]string{"1:plex/02 - across.flac"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("returns multiple files when querying multiple paths", func() { - results, err := mr.FindByPaths(ctx, []string{ + results, err := mr.FindByPaths([]string{ "1:artist/Album/track.mp3", "1:artist/Album/UPPER.mp3", }) @@ -1103,113 +690,28 @@ var _ = Describe("MediaRepository", func() { }) It("returns empty slice for non-existent paths", func() { - results, err := mr.FindByPaths(ctx, []string{"1:nonexistent/path.mp3"}) + results, err := mr.FindByPaths([]string{"1:nonexistent/path.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("returns empty slice for empty input", func() { - results, err := mr.FindByPaths(ctx, []string{}) + results, err := mr.FindByPaths([]string{}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty()) }) It("handles library-qualified paths correctly", func() { // Library 1 should find the file - results, err := mr.FindByPaths(ctx, []string{"1:artist/Album/track.mp3"}) + results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) // Library 2 should NOT find it (file is in library 1) - results, err = mr.FindByPaths(ctx, []string{"2:artist/Album/track.mp3"}) + results, err = mr.FindByPaths([]string{"2:artist/Album/track.mp3"}) 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(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(adminCtx, &otherLib)).To(Succeed()) - - // A track that lives only in the other library (created as admin) - adminMr := NewMediaFileRepository(GetDBXBuilder()) - Expect(adminMr.Put(adminCtx, &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(GetDBXBuilder()) - Expect(ur.Put(adminCtx, &restrictedUser)).To(Succeed()) - Expect(ur.SetUserLibraries(adminCtx, restrictedUser.ID, []int{1})).To(Succeed()) - }) - - AfterEach(func() { - adminCtx := request.WithUser(GinkgoT().Context(), adminUser) - _ = NewMediaFileRepository(GetDBXBuilder()).Delete(adminCtx, "otherlib-track") - lr := NewLibraryRepository(GetDBXBuilder()).(*libraryRepository) - _ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID}) - _ = NewUserRepository(GetDBXBuilder()).Delete(adminCtx, restrictedUser.ID) - }) - - It("does not resolve paths in libraries the user cannot access", func() { - userCtx := request.WithUser(ctx, restrictedUser) - userMr := NewMediaFileRepository(GetDBXBuilder()) - qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) - results, err := userMr.FindByPaths(userCtx, []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() { - adminCtx := request.WithUser(ctx, adminUser) - adminMr := NewMediaFileRepository(GetDBXBuilder()) - qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) - results, err := adminMr.FindByPaths(adminCtx, []string{qualified}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("otherlib-track")) - }) - - It("resolves paths from multiple libraries in a single call", func() { - adminCtx := request.WithUser(ctx, adminUser) - adminMr := NewMediaFileRepository(GetDBXBuilder()) - results, err := adminMr.FindByPaths(adminCtx, []string{ - "1:artist/Album/track.mp3", - fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID), - }) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - Expect([]string{results[0].ID, results[1].ID}).To(ConsistOf("findpath-1", "otherlib-track")) - }) - - It("keeps each path scoped to its own library when several are queried", func() { - adminCtx := request.WithUser(ctx, adminUser) - adminMr := NewMediaFileRepository(GetDBXBuilder()) - // Each path exists, but under the other library's ID, so neither must match. - results, err := adminMr.FindByPaths(adminCtx, []string{ - fmt.Sprintf("%d:artist/Album/track.mp3", otherLib.ID), - "1:hidden/test.mp3", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - }) - }) - - Describe("dbMediaFile.String", func() { - It("does not dereference a nil MediaFile", func() { - Expect(fmt.Sprint(dbMediaFile{})).To(Equal("")) - Expect(fmt.Sprint(&dbMediaFile{})).To(Equal("")) - }) }) Describe("wrapMediaFileCursor", func() { @@ -1231,7 +733,7 @@ var _ = Describe("MediaRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.MediaFile")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) @@ -1251,112 +753,4 @@ 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(ctx, &mf)).To(Succeed()) - - retrieved, err := mr.Get(ctx, 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(ctx, 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(ctx, &mf)).To(Succeed()) - - retrieved, err := mr.Get(ctx, 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(ctx, newID) - }) - }) - - Describe("AlbumImage hydration", func() { - It("carries the parent album's artwork state onto each track", func() { - mfs := model.MediaFiles{{ID: "mf-1", AlbumID: "al-1"}} - infos := map[string]model.ItemArtworkInfo{ - "al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8"}, - } - applyItemImage(infos, mfs[0].AlbumID, &mfs[0].AlbumImage) - - Expect(mfs[0].AlbumImage.ImageHash).To(Equal("0123456789abcdef")) - Expect(mfs[0].AlbumImage.BlurHash).To(Equal("LEHV6nWB2yk8")) - Expect(mfs[0].AlbumImage.ImageAbsent).To(BeFalse()) - }) - - It("keeps the album state independent of the track's own art", func() { - mf := model.MediaFile{ID: "mf-2", AlbumID: "al-2"} - mf.ImageHash = "aaaaaaaaaaaaaaaa" // the track's own art - applyItemImage( - map[string]model.ItemArtworkInfo{"al-2": {ItemID: "al-2", Hash: "bbbbbbbbbbbbbbbb"}}, - mf.AlbumID, &mf.AlbumImage, - ) - Expect(mf.ImageHash).To(Equal("aaaaaaaaaaaaaaaa")) - Expect(mf.AlbumImage.ImageHash).To(Equal("bbbbbbbbbbbbbbbb")) - }) - }) - - // Exists must apply the same library filter as Get/GetAll/CountAll. - Describe("Exists library visibility", func() { - It("hides a track the user has no library access to", func() { - restricted := model.User{ID: "restricted_mf_user", UserName: "rm", Name: "RM", Email: "rm@t.com"} - rctx := request.WithUser(GinkgoT().Context(), restricted) - - Expect(mr.Exists(ctx, songAntenna.ID)).To(BeTrue(), "admin sees it") - Expect(NewMediaFileRepository(GetDBXBuilder()).Exists(rctx, songAntenna.ID)).To(BeFalse()) - }) - }) - - Describe("MatchesCriteria", func() { - It("returns true when the track matches", func() { - c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Day"}}} - match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) - Expect(err).ToNot(HaveOccurred()) - Expect(match).To(BeTrue()) - }) - It("returns false when the track does not match", func() { - c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Nickelback"}}} - match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) - Expect(err).ToNot(HaveOccurred()) - Expect(match).To(BeFalse()) - }) - It("treats missing annotations as their COALESCE default", func() { - // unrated track: rating coalesces to 0, so "rating < 4" matches - c := criteria.Criteria{Expression: criteria.All{criteria.Lt{"rating": 4}}} - match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) - Expect(err).ToNot(HaveOccurred()) - Expect(match).To(BeTrue()) - }) - It("returns an error for an invalid field", func() { - c := criteria.Criteria{Expression: criteria.All{criteria.Is{"bogusfield": 1}}} - _, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) - Expect(err).To(HaveOccurred()) - }) - }) }) diff --git a/persistence/persistence.go b/persistence/persistence.go index 44e944bff..83211bdd5 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -3,8 +3,7 @@ package persistence import ( "context" "database/sql" - "fmt" - "sync" + "reflect" "time" "github.com/navidrome/navidrome/db" @@ -15,155 +14,125 @@ import ( ) type SQLStore struct { - db dbx.Builder - library func() model.LibraryRepository - folder func() model.FolderRepository - album func() model.AlbumRepository - artist func() model.ArtistRepository - mediaFile func() model.MediaFileRepository - genre func() model.GenreRepository - tag func() model.TagRepository - playlist func() model.PlaylistRepository - playQueue func() model.PlayQueueRepository - transcoding func() model.TranscodingRepository - player func() model.PlayerRepository - radio func() model.RadioRepository - share func() model.ShareRepository - property func() model.PropertyRepository - user func() model.UserRepository - userProps func() model.UserPropsRepository - scrobbleBuf func() model.ScrobbleBufferRepository - scrobble func() model.ScrobbleRepository - plugin func() model.PluginRepository - artwork func() model.ArtworkRepository - artworkQueue func() model.ArtworkQueueRepository -} - -// Repositories are built on first use, so a transaction store only pays for the ones its block touches. -func newSQLStore(db dbx.Builder) *SQLStore { - return &SQLStore{ - db: db, - library: sync.OnceValue(func() model.LibraryRepository { return NewLibraryRepository(db) }), - folder: sync.OnceValue(func() model.FolderRepository { return newFolderRepository(db) }), - album: sync.OnceValue(func() model.AlbumRepository { return NewAlbumRepository(db) }), - artist: sync.OnceValue(func() model.ArtistRepository { return NewArtistRepository(db) }), - mediaFile: sync.OnceValue(func() model.MediaFileRepository { return NewMediaFileRepository(db) }), - genre: sync.OnceValue(func() model.GenreRepository { return NewGenreRepository(db) }), - tag: sync.OnceValue(func() model.TagRepository { return NewTagRepository(db) }), - playlist: sync.OnceValue(func() model.PlaylistRepository { return NewPlaylistRepository(db) }), - playQueue: sync.OnceValue(func() model.PlayQueueRepository { return NewPlayQueueRepository(db) }), - transcoding: sync.OnceValue(func() model.TranscodingRepository { return NewTranscodingRepository(db) }), - player: sync.OnceValue(func() model.PlayerRepository { return NewPlayerRepository(db) }), - radio: sync.OnceValue(func() model.RadioRepository { return NewRadioRepository(db) }), - share: sync.OnceValue(func() model.ShareRepository { return NewShareRepository(db) }), - property: sync.OnceValue(func() model.PropertyRepository { return NewPropertyRepository(db) }), - user: sync.OnceValue(func() model.UserRepository { return NewUserRepository(db) }), - userProps: sync.OnceValue(func() model.UserPropsRepository { return NewUserPropsRepository(db) }), - scrobbleBuf: sync.OnceValue(func() model.ScrobbleBufferRepository { return NewScrobbleBufferRepository(db) }), - scrobble: sync.OnceValue(func() model.ScrobbleRepository { return NewScrobbleRepository(db) }), - plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }), - artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }), - artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }), - } + db dbx.Builder } func New(conn *sql.DB) model.DataStore { - return newSQLStore(dbx.NewFromDB(conn, db.Driver)) + return &SQLStore{db: dbx.NewFromDB(conn, db.Driver)} } -func (s *SQLStore) Album() model.AlbumRepository { - return s.album() +func (s *SQLStore) Album(ctx context.Context) model.AlbumRepository { + return NewAlbumRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Artist() model.ArtistRepository { - return s.artist() +func (s *SQLStore) Artist(ctx context.Context) model.ArtistRepository { + return NewArtistRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) MediaFile() model.MediaFileRepository { - return s.mediaFile() +func (s *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository { + return NewMediaFileRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Library() model.LibraryRepository { - return s.library() +func (s *SQLStore) Library(ctx context.Context) model.LibraryRepository { + return NewLibraryRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Folder() model.FolderRepository { - return s.folder() +func (s *SQLStore) Folder(ctx context.Context) model.FolderRepository { + return newFolderRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Genre() model.GenreRepository { - return s.genre() +func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository { + return NewGenreRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Tag() model.TagRepository { - return s.tag() +func (s *SQLStore) Tag(ctx context.Context) model.TagRepository { + return NewTagRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) PlayQueue() model.PlayQueueRepository { - return s.playQueue() +func (s *SQLStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { + return NewPlayQueueRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Playlist() model.PlaylistRepository { - return s.playlist() +func (s *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository { + return NewPlaylistRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Property() model.PropertyRepository { - return s.property() +func (s *SQLStore) Property(ctx context.Context) model.PropertyRepository { + return NewPropertyRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Radio() model.RadioRepository { - return s.radio() +func (s *SQLStore) Radio(ctx context.Context) model.RadioRepository { + return NewRadioRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) UserProps() model.UserPropsRepository { - return s.userProps() +func (s *SQLStore) UserProps(ctx context.Context) model.UserPropsRepository { + return NewUserPropsRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Share() model.ShareRepository { - return s.share() +func (s *SQLStore) Share(ctx context.Context) model.ShareRepository { + return NewShareRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) User() model.UserRepository { - return s.user() +func (s *SQLStore) User(ctx context.Context) model.UserRepository { + return NewUserRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Transcoding() model.TranscodingRepository { - return s.transcoding() +func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository { + return NewTranscodingRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Player() model.PlayerRepository { - return s.player() +func (s *SQLStore) Player(ctx context.Context) model.PlayerRepository { + return NewPlayerRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) ScrobbleBuffer() model.ScrobbleBufferRepository { - return s.scrobbleBuf() +func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { + return NewScrobbleBufferRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Scrobble() model.ScrobbleRepository { - return s.scrobble() +func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository { + return NewScrobbleRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Plugin() model.PluginRepository { - return s.plugin() +func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository { + return NewPluginRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) Artwork() model.ArtworkRepository { - return s.artwork() -} - -func (s *SQLStore) ArtworkQueue() model.ArtworkQueueRepository { - return s.artworkQueue() -} - -func scopeLabel(scope []string) string { - if len(scope) > 0 { - return scope[0] +func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository { + switch m.(type) { + case model.User: + return s.User(ctx).(model.ResourceRepository) + case model.Transcoding: + return s.Transcoding(ctx).(model.ResourceRepository) + case model.Player: + return s.Player(ctx).(model.ResourceRepository) + case model.Artist: + return s.Artist(ctx).(model.ResourceRepository) + case model.Album: + return s.Album(ctx).(model.ResourceRepository) + case model.MediaFile: + return s.MediaFile(ctx).(model.ResourceRepository) + case model.Genre: + return s.Genre(ctx).(model.ResourceRepository) + case model.Playlist: + return s.Playlist(ctx).(model.ResourceRepository) + case model.Radio: + return s.Radio(ctx).(model.ResourceRepository) + case model.Share: + return s.Share(ctx).(model.ResourceRepository) + case model.Tag: + return s.Tag(ctx).(model.ResourceRepository) + case model.Plugin: + return s.Plugin(ctx).(model.ResourceRepository) } - return "" + log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name()) + return nil } func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) error { - msg := scopeLabel(scope) + var msg string + if len(scope) > 0 { + msg = scope[0] + } start := time.Now() conn, inTx := s.db.(*dbx.DB) if !inTx { @@ -173,7 +142,7 @@ func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) log.Trace("Transaction started", "scope", msg) } return conn.Transactional(func(tx *dbx.Tx) error { - newDb := newSQLStore(tx) + newDb := &SQLStore{db: tx} err := block(newDb) if !inTx { log.Trace("Nested Transaction finished", "scope", msg, "elapsed", time.Since(start), err) @@ -189,60 +158,15 @@ func (s *SQLStore) WithTxImmediate(block func(tx model.DataStore) error, scope . return s.WithTx(func(tx model.DataStore) error { // Workaround to force the transaction to be upgraded to immediate mode to avoid deadlocks // See https://berthub.eu/articles/posts/a-brief-post-on-sqlite3-database-locked-despite-timeout/ - _ = tx.Property().Put(ctx, "tmp_lock_flag", "") + _ = tx.Property(ctx).Put("tmp_lock_flag", "") defer func() { - _ = tx.Property().Delete(ctx, "tmp_lock_flag") + _ = tx.Property(ctx).Delete("tmp_lock_flag") }() return block(tx) }, scope...) } -// txRetryDelay spaces out reruns of a busy transaction. Each attempt has already waited out the -// busy timeout, so WithTxRetry gives up only after a sustained lock. -var txRetryDelay = 5 * time.Second - -const txMaxRetries = 3 - -func (s *SQLStore) WithTxRetry(ctx context.Context, block func(ctx context.Context, tx model.DataStore) error, scope ...string) error { - // Inside a transaction, join it: the outer one holds the lock and owns commit and rollback - if _, ok := s.db.(*dbx.DB); !ok { - return block(ctx, s) - } - for attempt := 0; ; attempt++ { - attemptCtx := ctx - if attempt < txMaxRetries { - attemptCtx = withBusyRetry(ctx) - } - err := s.WithTx(func(tx model.DataStore) error { return block(attemptCtx, tx) }, scope...) - if attempt == txMaxRetries || !db.IsBusy(err) { - return err - } - log.Warn(ctx, "Database busy, retrying transaction", "scope", scopeLabel(scope), "attempt", attempt+1, err) - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(time.Duration(attempt+1) * txRetryDelay): - } - } -} - -type busyRetryKey struct{} - -// withBusyRetry marks a transaction attempt that WithTxRetry will rerun, so a busy statement in it -// is logged as a warning rather than an error. -func withBusyRetry(ctx context.Context) context.Context { - return context.WithValue(ctx, busyRetryKey{}, true) -} - -func hasBusyRetry(ctx context.Context) bool { - if ctx == nil { - return false - } - retry, _ := ctx.Value(busyRetryKey{}).(bool) - return retry -} - func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace := func(ctx context.Context, msg string, f func() error) func() error { return func() error { @@ -260,20 +184,26 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { } err := run.Sequentially( - trace(ctx, "purge empty albums", func() error { return s.album().(*albumRepository).purgeEmpty(ctx, libraryIDs...) }), - trace(ctx, "purge empty artists", func() error { return s.artist().(*artistRepository).purgeEmpty(ctx) }), - trace(ctx, "mark missing artists", func() error { return s.artist().(*artistRepository).markMissing(ctx) }), - trace(ctx, "purge empty folders", func() error { return s.folder().(*folderRepository).purgeEmpty(ctx, libraryIDs...) }), - trace(ctx, "clean album annotations", func() error { return s.album().(*albumRepository).cleanAnnotations(ctx) }), - trace(ctx, "clean artist annotations", func() error { return s.artist().(*artistRepository).cleanAnnotations(ctx) }), - trace(ctx, "clean media file annotations", func() error { return s.mediaFile().(*mediaFileRepository).cleanAnnotations(ctx) }), - trace(ctx, "clean playlist annotations", func() error { return s.playlist().(*playlistRepository).cleanAnnotations(ctx) }), - trace(ctx, "clean media file bookmarks", func() error { return s.mediaFile().(*mediaFileRepository).cleanBookmarks(ctx) }), - trace(ctx, "purge non used tags", func() error { return s.tag().(*tagRepository).purgeUnused(ctx) }), - trace(ctx, "remove orphan playlist tracks", func() error { return s.playlist().(*playlistRepository).removeOrphans(ctx) }), + trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty(libraryIDs...) }), + trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }), + trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }), + trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty(libraryIDs...) }), + 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 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() }), ) if err != nil { - return fmt.Errorf("tidying up database: %w", err) + log.Error(ctx, "Error tidying up database", err) } - return nil + return err +} + +func (s *SQLStore) getDBXBuilder() dbx.Builder { + if s.db == nil { + return dbx.NewFromDB(db.Db(), db.Driver) + } + return s.db } diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index ee2794454..ebc247d77 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -4,7 +4,6 @@ import ( "context" "path/filepath" "testing" - "time" "github.com/Masterminds/squirrel" _ "github.com/mattn/go-sqlite3" @@ -14,6 +13,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" "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: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0), + RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0), }) songAntennaWithLyrics = mf(model.MediaFile{ ID: "1005", @@ -158,47 +158,20 @@ 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) } -// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so -// specs can assert that a query filters by library. Cleans itself up after the spec. -func restrictedFixture(name string) (context.Context, model.Library, model.User) { - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - db := GetDBXBuilder() - - lib := model.Library{Name: name + " Library", Path: "/" + name} - lr := NewLibraryRepository(db) - Expect(lr.Put(adminCtx, &lib)).To(Succeed()) - - user := createUserWithLibraries(name+"-restricted", []int{1}) - ur := NewUserRepository(db) - Expect(ur.Put(adminCtx, &user)).To(Succeed()) - Expect(ur.SetUserLibraries(adminCtx, user.ID, []int{1})).To(Succeed()) - - DeferCleanup(func() { - _ = NewUserRepository(db).Delete(adminCtx, user.ID) - _ = NewLibraryRepository(db).(*libraryRepository).delete(adminCtx, squirrel.Eq{"id": lib.ID}) - }) - return adminCtx, lib, user -} - +// Initialize test DB +// TODO Load this data setup from file(s) var _ = BeforeSuite(func() { conn := GetDBXBuilder() ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, adminUser) - ur := NewUserRepository(conn) + ur := NewUserRepository(ctx, conn) for i := range testUsers { - err := ur.Put(ctx, &testUsers[i]) + err := ur.Put(&testUsers[i]) if err != nil { panic(err) } @@ -206,32 +179,34 @@ var _ = BeforeSuite(func() { // Associate users with library 1 (default test library) for i := range testUsers { - err := ur.SetUserLibraries(ctx, testUsers[i].ID, []int{1}) + err := ur.SetUserLibraries(testUsers[i].ID, []int{1}) if err != nil { panic(err) } } - alr := NewAlbumRepository(conn).(*albumRepository) + alr := NewAlbumRepository(ctx, conn).(*albumRepository) for i := range testAlbums { - err := alr.Put(ctx, new(testAlbums[i])) + a := testAlbums[i] + err := alr.Put(&a) if err != nil { panic(err) } } - arr := NewArtistRepository(conn) + arr := NewArtistRepository(ctx, conn) for i := range testArtists { - err := arr.Put(ctx, new(testArtists[i])) + a := testArtists[i] + err := arr.Put(&a) if err != nil { panic(err) } } // Associate artists with library 1 (default test library) - lr := NewLibraryRepository(conn) + lr := NewLibraryRepository(ctx, conn) for i := range testArtists { - err := lr.AddArtist(ctx, 1, testArtists[i].ID) + err := lr.AddArtist(1, testArtists[i].ID) if err != nil { panic(err) } @@ -247,7 +222,7 @@ var _ = BeforeSuite(func() { if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] { continue } - _, err := alr.executeSQL(ctx, squirrel.Insert("album_artists").SetMap(map[string]any{ + _, err := alr.executeSQL(squirrel.Insert("album_artists").SetMap(map[string]any{ "album_id": a.ID, "artist_id": a.AlbumArtistID, "role": "artist", @@ -258,17 +233,18 @@ var _ = BeforeSuite(func() { } } - mr := NewMediaFileRepository(conn) + mr := NewMediaFileRepository(ctx, conn) for i := range testSongs { - err := mr.Put(ctx, &testSongs[i]) + err := mr.Put(&testSongs[i]) if err != nil { panic(err) } } - rar := NewRadioRepository(conn) + rar := NewRadioRepository(ctx, conn) for i := range testRadios { - err := rar.Put(ctx, new(testRadios[i])) + r := testRadios[i] + err := rar.Put(&r) if err != nil { panic(err) } @@ -287,19 +263,19 @@ var _ = BeforeSuite(func() { plsCool.AddMediaFilesByID([]string{"1004"}) testPlaylists = []*model.Playlist{&plsBest, &plsCool} - pr := NewPlaylistRepository(conn) + pr := NewPlaylistRepository(ctx, conn) for i := range testPlaylists { - err := pr.Put(ctx, testPlaylists[i]) + err := pr.Put(testPlaylists[i]) if err != nil { panic(err) } } // Prepare annotations - if err := arr.SetStar(ctx, true, artistBeatles.ID); err != nil { + if err := arr.SetStar(true, artistBeatles.ID); err != nil { panic(err) } - ar, err := arr.Get(ctx, artistBeatles.ID) + ar, err := arr.Get(artistBeatles.ID) if err != nil { panic(err) } @@ -310,10 +286,10 @@ var _ = BeforeSuite(func() { artistBeatles.StarredAt = ar.StarredAt testArtists[1] = artistBeatles - if err := alr.SetStar(ctx, true, albumRadioactivity.ID); err != nil { + if err := alr.SetStar(true, albumRadioactivity.ID); err != nil { panic(err) } - al, err := alr.Get(ctx, albumRadioactivity.ID) + al, err := alr.Get(albumRadioactivity.ID) if err != nil { panic(err) } @@ -324,43 +300,18 @@ var _ = BeforeSuite(func() { albumRadioactivity.StarredAt = al.StarredAt testAlbums[2] = albumRadioactivity - if err := mr.SetStar(ctx, true, songComeTogether.ID); err != nil { + if err := mr.SetStar(true, songComeTogether.ID); err != nil { panic(err) } - mf, err := mr.Get(ctx, songComeTogether.ID) + mf, err := mr.Get(songComeTogether.ID) if err != nil { panic(err) } songComeTogether.Starred = true songComeTogether.StarredAt = mf.StarredAt testSongs[1] = songComeTogether - - scrobbleRepo := NewScrobbleRepository(conn).(*scrobbleRepository) - for _, s := range scrobbles { - _, err := scrobbleRepo.executeSQL(ctx, squirrel.Insert("scrobbles").SetMap(map[string]any{ - "media_file_id": s.MediaFileID, - "user_id": s.UserID, - "submission_time": s.SubmissionTime, - })) - if err != nil { - panic(err) - } - } }) func GetDBXBuilder() *dbx.DB { return dbx.NewFromDB(db.Db(), db.Dialect) } - -// collectCursor takes the cursor's underlying func type so the named cursor types -// (model.AlbumCursor, ...) infer T. -func collectCursor[T any](cursor func(func(T, error) bool), err error) []T { - GinkgoHelper() - Expect(err).ToNot(HaveOccurred()) - var out []T - for item, err := range cursor { - Expect(err).ToNot(HaveOccurred()) - out = append(out, item) - } - return out -} diff --git a/persistence/persistence_test.go b/persistence/persistence_test.go index 43d2e81ed..13e56bde1 100644 --- a/persistence/persistence_test.go +++ b/persistence/persistence_test.go @@ -2,10 +2,7 @@ package persistence import ( "context" - "errors" - "time" - "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" @@ -23,109 +20,39 @@ var _ = Describe("SQLStore", func() { Context("When block returns nil", func() { It("commits changes to the DB", func() { err := ds.WithTx(func(tx model.DataStore) error { - pl := tx.Player() - err := pl.Put(ctx, &model.Player{ID: "666", UserId: "userid"}) + pl := tx.Player(ctx) + err := pl.Put(&model.Player{ID: "666", UserId: "userid"}) Expect(err).ToNot(HaveOccurred()) - pr := tx.Property() - err = pr.Put(ctx, "777", "value") + pr := tx.Property(ctx) + err = pr.Put("777", "value") Expect(err).ToNot(HaveOccurred()) return nil }) Expect(err).ToNot(HaveOccurred()) - Expect(ds.Player().Get(ctx, "666")).To(Equal(&model.Player{ID: "666", UserId: "userid", Username: "userid"})) - Expect(ds.Property().Get(ctx, "777")).To(Equal("value")) + Expect(ds.Player(ctx).Get("666")).To(Equal(&model.Player{ID: "666", UserId: "userid", Username: "userid"})) + Expect(ds.Property(ctx).Get("777")).To(Equal("value")) }) }) Context("When block returns an error", func() { It("rollbacks changes to the DB", func() { err := ds.WithTx(func(tx model.DataStore) error { - pr := tx.Property() - err := pr.Put(ctx, "999", "value") + pr := tx.Property(ctx) + err := pr.Put("999", "value") Expect(err).ToNot(HaveOccurred()) // Will fail as it is missing the UserName - pl := tx.Player() - err = pl.Put(ctx, &model.Player{ID: "888"}) + pl := tx.Player(ctx) + err = pl.Put(&model.Player{ID: "888"}) Expect(err).To(HaveOccurred()) return err }) Expect(err).To(HaveOccurred()) - _, err = ds.Property().Get(ctx, "999") + _, err = ds.Property(ctx).Get("999") Expect(err).To(MatchError(model.ErrNotFound)) - _, err = ds.Player().Get(ctx, "888") + _, err = ds.Player(ctx).Get("888") Expect(err).To(MatchError(model.ErrNotFound)) }) }) }) - - Describe("WithTxRetry", func() { - busy := sqlite3.Error{Code: sqlite3.ErrBusy} - BeforeEach(func() { - DeferCleanup(func(d time.Duration) { txRetryDelay = d }, txRetryDelay) - txRetryDelay = 0 - }) - - It("reruns a busy transaction from a clean rollback", func() { - var attempts []bool - err := ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - attempts = append(attempts, hasBusyRetry(ctx)) - Expect(tx.Property().Put(ctx, "retry-key", "attempt")).To(Succeed()) - if len(attempts) < 3 { - return busy - } - return nil - }) - Expect(err).ToNot(HaveOccurred()) - Expect(attempts).To(Equal([]bool{true, true, true})) - Expect(ds.Property().Get(ctx, "retry-key")).To(Equal("attempt")) - }) - - It("gives up after the last retry, which is not marked as retried", func() { - var attempts []bool - err := ds.WithTxRetry(ctx, func(ctx context.Context, _ model.DataStore) error { - attempts = append(attempts, hasBusyRetry(ctx)) - return busy - }) - Expect(db.IsBusy(err)).To(BeTrue()) - Expect(attempts).To(Equal([]bool{true, true, true, false})) - }) - - It("does not rerun on other errors", func() { - calls := 0 - err := ds.WithTxRetry(ctx, func(context.Context, model.DataStore) error { - calls++ - return sqlite3.Error{Code: sqlite3.ErrConstraint} - }) - Expect(err).To(HaveOccurred()) - Expect(calls).To(Equal(1)) - }) - - It("does not rerun when called inside a transaction", func() { - calls := 0 - err := ds.WithTx(func(tx model.DataStore) error { - return tx.WithTxRetry(ctx, func(context.Context, model.DataStore) error { - calls++ - return busy - }) - }) - Expect(db.IsBusy(err)).To(BeTrue()) - Expect(calls).To(Equal(1)) - }) - - It("joins the enclosing transaction instead of opening another", func() { - rollback := errors.New("rollback") - err := ds.WithTx(func(tx model.DataStore) error { - Expect(tx.Property().Put(ctx, "outer-key", "v")).To(Succeed()) - Expect(tx.WithTxRetry(ctx, func(ctx context.Context, inner model.DataStore) error { - Expect(inner.Property().Get(ctx, "outer-key")).To(Equal("v")) - return inner.Property().Put(ctx, "inner-key", "v") - })).To(Succeed()) - return rollback - }) - Expect(err).To(MatchError(rollback)) - _, err = ds.Property().Get(ctx, "inner-key") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) }) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index ba5d26794..6c8339378 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -2,16 +2,11 @@ package persistence import ( "context" - "crypto/sha256" - "encoding/hex" - "regexp" - "strings" + "errors" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" "github.com/pocketbase/dbx" ) @@ -19,12 +14,12 @@ type playerRepository struct { sqlRepository } -func NewPlayerRepository(db dbx.Builder) model.PlayerRepository { +func NewPlayerRepository(ctx context.Context, db dbx.Builder) model.PlayerRepository { r := &playerRepository{} + r.ctx = ctx r.db = db r.registerModel(&model.Player{}, map[string]filterFunc{ - "name": containsFilter("player.name"), - "hasapikey": hasAPIKeyFilter, + "name": containsFilter("player.name"), }) r.setSortMappings(map[string]string{ "user_name": "username", //TODO rename all user_name and userName to username @@ -32,50 +27,55 @@ func NewPlayerRepository(db dbx.Builder) model.PlayerRepository { return r } -func hasAPIKeyFilter(_ string, value any) Sqlizer { - if v, _ := value.(string); strings.EqualFold(v, "true") { - return NotEq{"player.api_key_hash": nil} - } - return Eq{"player.api_key_hash": nil} -} - -func (r *playerRepository) Put(ctx context.Context, p *model.Player) error { - _, err := r.put(ctx, p.ID, p) +func (r *playerRepository) Put(p *model.Player) error { + _, err := r.put(p.ID, p) return err } -func (r *playerRepository) selectPlayer(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - return r.newSelect(ctx, options...). - Columns("player.*", "player.api_key_hash is not null as has_api_key"). +func (r *playerRepository) selectPlayer(options ...model.QueryOptions) SelectBuilder { + return r.newSelect(options...). + Columns("player.*"). Join("user ON player.user_id = user.id"). Columns("user.user_name username") } -func (r *playerRepository) Get(ctx context.Context, id string) (*model.Player, error) { - sel := r.selectPlayer(ctx).Where(Eq{"player.id": id}) +func (r *playerRepository) Get(id string) (*model.Player, error) { + sel := r.selectPlayer().Where(Eq{"player.id": id}) var res model.Player - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *playerRepository) FindMatch(ctx context.Context, userId, client, userAgent string) (*model.Player, error) { - sel := r.selectPlayer(ctx).Where(And{ +func (r *playerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) { + sel := r.selectPlayer().Where(And{ Eq{"client": client}, Eq{"user_agent": userAgent}, Eq{"user_id": userId}, }) var res model.Player - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *playerRepository) newRestSelect(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - s := r.selectPlayer(ctx, options...) - return s.Where(r.addRestriction(ctx)) +func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder { + s := r.selectPlayer(options...) + return s.Where(r.addRestriction()) } -func (r *playerRepository) CountByClient(ctx context.Context, options ...model.QueryOptions) (map[string]int64, error) { - sel := r.newSelect(ctx, options...). +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( "case when client = 'NavidromeUI' then name else client end as player", "count(*) as count", @@ -84,7 +84,7 @@ func (r *playerRepository) CountByClient(ctx context.Context, options ...model.Q Player string Count int64 } - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) if err != nil { return nil, err } @@ -95,137 +95,75 @@ func (r *playerRepository) CountByClient(ctx context.Context, options ...model.Q return counts, nil } -func (r *playerRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - return r.count(ctx, r.newRestSelect(ctx), options...) +func (r *playerRepository) CountAll(options ...model.QueryOptions) (int64, error) { + return r.count(r.newRestSelect(), options...) } -func (r *playerRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *playerRepository) Read(ctx context.Context, id string) (*model.Player, error) { - sel := r.newRestSelect(ctx).Where(Eq{"player.id": id}) +func (r *playerRepository) Read(id string) (any, error) { + sel := r.newRestSelect().Where(Eq{"player.id": id}) var res model.Player - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *playerRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Player, error) { - sel := r.newRestSelect(ctx, r.parseRestOptions(ctx, options...)) +func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...)) res := model.Players{} - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) return res, err } -var apiKeyFormat = regexp.MustCompile(`^` + consts.APIKeyPrefix + `[0-9A-Za-z]{22}$`) - -func apiKeyValidationError(msg string) error { - return &rest.ValidationError{Errors: map[string]string{"apiKey": msg}} +func (r *playerRepository) EntityName() string { + return "player" } -func validateAPIKey(key string) error { - if !apiKeyFormat.MatchString(key) { - return apiKeyValidationError("resources.player.validation.apiKeyFormat") - } - return nil +func (r *playerRepository) NewInstance() any { + return &model.Player{} } -func (r *playerRepository) Save(ctx context.Context, t *model.Player) (string, error) { - u := loggedUser(ctx) - if t.UserId == "" && u.ID != invalidUserId { - t.UserId = u.ID - } - if t.UserId != u.ID { +func (r *playerRepository) isPermitted(p *model.Player) bool { + u := loggedUser(r.ctx) + return u.IsAdmin || p.UserId == u.ID +} + +func (r *playerRepository) Save(entity any) (string, error) { + t := entity.(*model.Player) + if !r.isPermitted(t) { return "", rest.ErrPermissionDenied } - // Hand-made players are only reachable through a key, so one is required - if t.APIKey == nil || *t.APIKey == "" { - return "", apiKeyValidationError("ra.validation.required") + id, err := r.put(t.ID, t) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound } - if err := validateAPIKey(*t.APIKey); err != nil { - return "", err - } - values, err := toSQLArgs(t) - if err != nil { - return "", err - } - // Save only creates, so the key hash goes in the same INSERT and the unique index settles races - values["id"] = id.NewRandom() - values["api_key_hash"] = hashAPIKey(*t.APIKey) - _, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values)) - if isUniqueViolation(err) { - return "", apiKeyValidationError("ra.validation.unique") - } - if err != nil { - return "", err - } - return values["id"].(string), nil + return id, err } -func (r *playerRepository) Update(ctx context.Context, id string, entity model.Player, cols ...string) error { - t := &entity +func (r *playerRepository) Update(id string, entity any, cols ...string) error { + t := entity.(*model.Player) t.ID = id - if t.APIKey == nil { - return r.updateOwned(ctx, id, t, cols...) + if !r.isPermitted(t) { + return rest.ErrPermissionDenied } - // The key and the other columns are two writes; commit both or neither - return r.inTx(func(tx *playerRepository) error { - if err := tx.SetAPIKey(ctx, id, *t.APIKey); err != nil { - return err - } - return tx.updateOwned(ctx, id, t, cols...) - }) -} - -func (r *playerRepository) inTx(block func(tx *playerRepository) error) error { - conn, ok := r.db.(*dbx.DB) - if !ok { - return block(r) // already inside a transaction - } - return conn.Transactional(func(tx *dbx.Tx) error { - return block(NewPlayerRepository(tx).(*playerRepository)) - }) -} - -func (r *playerRepository) Delete(ctx context.Context, ids ...string) error { - return r.deleteOwnedAll(ctx, ids...) -} - -// Keys are long random strings, not user-chosen passwords, so a fast unsalted hash is enough and keeps lookups indexed. -func hashAPIKey(key string) string { - sum := sha256.Sum256([]byte(key)) - return hex.EncodeToString(sum[:]) -} - -func (r *playerRepository) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) { - sel := r.selectPlayer(ctx).Where(Eq{"player.api_key_hash": hashAPIKey(key)}) - var res model.Player - if err := r.queryOne(ctx, sel, &res); err != nil { - return nil, err - } - return &res, nil -} - -// SetAPIKey stores the key's hash, or revokes it when key is empty. Setting is owner-only, even for -// admins, so nobody can mint a login for someone else. -func (r *playerRepository) SetAPIKey(ctx context.Context, playerID, key string) error { - if key == "" { - return r.updateOwnedRow(ctx, playerID, ownerOrAdmin, map[string]any{"api_key_hash": nil}) - } - if err := validateAPIKey(key); err != nil { - return err - } - err := r.updateOwnedRow(ctx, playerID, ownerOnly, map[string]any{"api_key_hash": hashAPIKey(key)}) - if isUniqueViolation(err) { - return apiKeyValidationError("ra.validation.unique") + _, err := r.put(id, t, cols...) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound } return err } -func isUniqueViolation(err error) bool { - return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +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 } var _ model.PlayerRepository = (*playerRepository)(nil) -var _ rest.Repository[model.Player] = (*playerRepository)(nil) -var _ rest.Persistable[model.Player] = (*playerRepository)(nil) +var _ rest.Repository = (*playerRepository)(nil) +var _ rest.Persistable = (*playerRepository)(nil) diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index 69afa9556..f6c669493 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "errors" "github.com/deluan/rest" "github.com/navidrome/navidrome/log" @@ -13,18 +12,9 @@ import ( "github.com/pocketbase/dbx" ) -const testAPIKey = "nds_0123456789abcdefghijkl" - -func expectAPIKeyError(err error, msg string) { - var verr *rest.ValidationError - ExpectWithOffset(1, errors.As(err, &verr)).To(BeTrue()) - ExpectWithOffset(1, verr.Errors).To(HaveKeyWithValue("apiKey", msg)) -} - var _ = Describe("PlayerRepository", func() { var adminRepo *playerRepository var database *dbx.DB - var ctx context.Context var ( adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true} @@ -35,69 +25,77 @@ var _ = Describe("PlayerRepository", func() { ) BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) database = GetDBXBuilder() - adminRepo = NewPlayerRepository(database).(*playerRepository) + adminRepo = NewPlayerRepository(ctx, database).(*playerRepository) for idx := range players { - err := adminRepo.Put(ctx, &players[idx]) + err := adminRepo.Put(&players[idx]) Expect(err).To(BeNil()) } }) AfterEach(func() { - players, err := adminRepo.ReadAll(ctx) + items, err := adminRepo.ReadAll() Expect(err).To(BeNil()) + players, ok := items.(model.Players) + Expect(ok).To(BeTrue()) for i := range players { - err = adminRepo.Delete(ctx, players[i].ID) + err = adminRepo.Delete(players[i].ID) Expect(err).To(BeNil()) } }) + Describe("EntityName", func() { + It("returns the right name", func() { + Expect(adminRepo.EntityName()).To(Equal("player")) + }) + }) + Describe("FindMatch", func() { It("finds existing match", func() { - player, err := adminRepo.FindMatch(ctx, adminUser.ID, "NavidromeUI", "Firefox/Linux") + player, err := adminRepo.FindMatch(adminUser.ID, "NavidromeUI", "Firefox/Linux") Expect(err).To(BeNil()) Expect(*player).To(Equal(adminPlayer1)) }) It("doesn't find bad match", func() { - _, err := adminRepo.FindMatch(ctx, regularUser.ID, "NavidromeUI", "Firefox/Linux") + _, err := adminRepo.FindMatch(regularUser.ID, "NavidromeUI", "Firefox/Linux") Expect(err).To(Equal(model.ErrNotFound)) }) }) Describe("Get", func() { It("Gets an existing item from user", func() { - player, err := adminRepo.Get(ctx, adminPlayer1.ID) + player, err := adminRepo.Get(adminPlayer1.ID) Expect(err).To(BeNil()) Expect(*player).To(Equal(adminPlayer1)) }) It("Gets an existing item from another user", func() { - player, err := adminRepo.Get(ctx, regularPlayer.ID) + player, err := adminRepo.Get(regularPlayer.ID) Expect(err).To(BeNil()) Expect(*player).To(Equal(regularPlayer)) }) It("does not get nonexistent item", func() { - _, err := adminRepo.Get(ctx, "i don't exist") + _, err := adminRepo.Get("i don't exist") Expect(err).To(Equal(model.ErrNotFound)) }) }) DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) { var repo *playerRepository - var repoCtx context.Context BeforeEach(func() { - repoCtx = ctx if admin { repo = adminRepo } else { - repoCtx = request.WithUser(ctx, regularUser) - repo = NewPlayerRepository(database).(*playerRepository) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, regularUser) + repo = NewPlayerRepository(ctx, database).(*playerRepository) } }) @@ -105,61 +103,51 @@ var _ = Describe("PlayerRepository", func() { Describe("Count", func() { It("should return all", func() { - count, err := repo.Count(repoCtx) + count, err := repo.Count() Expect(err).To(BeNil()) Expect(count).To(Equal(baseCount)) }) }) Describe("Delete", func() { - It("deletes a player owned by the current user", func() { - err := repo.Delete(repoCtx, userPlayer.ID) + DescribeTable("item type", func(player model.Player) { + err := repo.Delete(player.ID) Expect(err).To(BeNil()) - count, err := repo.Count(repoCtx) + isReal := player.UserId != "" + canDelete := admin || player.UserId == userPlayer.UserId + + count, err := repo.Count() Expect(err).To(BeNil()) - Expect(count).To(Equal(baseCount - 1)) - _, err = repo.Get(repoCtx, userPlayer.ID) - Expect(err).To(Equal(model.ErrNotFound)) - }) + if isReal && canDelete { + Expect(count).To(Equal(baseCount - 1)) + } else { + Expect(count).To(Equal(baseCount)) + } - It("does not delete another user's player when not admin", func() { - err := repo.Delete(repoCtx, otherPlayer.ID) - - if admin { - // Admins may delete any player. - Expect(err).To(BeNil()) - Expect(repo.Count(repoCtx)).To(Equal(baseCount - 1)) - _, err = repo.Get(repoCtx, otherPlayer.ID) + item, err := repo.Get(player.ID) + if !isReal || canDelete { Expect(err).To(Equal(model.ErrNotFound)) } else { - // 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(repoCtx)).To(Equal(baseCount)) - item, err := repo.Get(repoCtx, otherPlayer.ID) - Expect(err).To(BeNil()) - Expect(*item).To(Equal(otherPlayer)) + Expect(*item).To(Equal(player)) } - }) - - It("returns not-found for a nonexistent player", func() { - err := repo.Delete(repoCtx, "i don't exist") - Expect(err).To(Equal(rest.ErrNotFound)) - Expect(repo.Count(repoCtx)).To(Equal(baseCount)) - }) + }, + Entry("same user", userPlayer), + Entry("other item", otherPlayer), + Entry("fake item", model.Player{}), + ) }) Describe("Read", func() { It("can read from current user", func() { - player, err := repo.Read(repoCtx, userPlayer.ID) + player, err := repo.Read(userPlayer.ID) Expect(err).To(BeNil()) Expect(player).To(Equal(&userPlayer)) }) It("can read from other user or fail if not admin", func() { - player, err := repo.Read(repoCtx, otherPlayer.ID) + player, err := repo.Read(otherPlayer.ID) if admin { Expect(err).To(BeNil()) Expect(player).To(Equal(&otherPlayer)) @@ -169,16 +157,16 @@ var _ = Describe("PlayerRepository", func() { }) It("does not get nonexistent item", func() { - _, err := repo.Read(repoCtx, "i don't exist") + _, err := repo.Read("i don't exist") Expect(err).To(Equal(model.ErrNotFound)) }) }) Describe("ReadAll", func() { It("should get all items", func() { - data, err := repo.ReadAll(repoCtx) + data, err := repo.ReadAll() Expect(err).To(BeNil()) - Expect(model.Players(data)).To(Equal(players)) + Expect(data).To(Equal(players)) }) }) @@ -187,12 +175,11 @@ var _ = Describe("PlayerRepository", func() { clone := player clone.ID = "" clone.IP = "192.168.1.1" - clone.APIKey = new(testAPIKey) - id, err := repo.Save(repoCtx, &clone) + id, err := repo.Save(&clone) if clone.UserId == "" { Expect(err).To(HaveOccurred()) - } else if player.UserId != userPlayer.UserId { + } else if !admin && player.Username == adminPlayer1.Username { Expect(err).To(Equal(rest.ErrPermissionDenied)) clone.UserId = "" } else { @@ -200,11 +187,11 @@ var _ = Describe("PlayerRepository", func() { Expect(id).ToNot(BeEmpty()) } - count, err := repo.Count(repoCtx) + count, err := repo.Count() Expect(err).To(BeNil()) clone.ID = id - newItem, err := repo.Get(repoCtx, id) + newItem, err := repo.Get(id) if clone.UserId == "" { Expect(count).To(Equal(baseCount)) @@ -212,13 +199,12 @@ var _ = Describe("PlayerRepository", func() { } else { Expect(count).To(Equal(baseCount + 1)) Expect(err).To(BeNil()) - clone.APIKey = nil - clone.HasAPIKey = true Expect(*newItem).To(Equal(clone)) } }, Entry("same user", userPlayer), Entry("other item", otherPlayer), + Entry("fake item", model.Player{}), ) }) @@ -227,14 +213,11 @@ var _ = Describe("PlayerRepository", func() { clone := player clone.IP = "192.168.1.1" clone.MaxBitRate = 10000 - err := repo.Update(repoCtx, clone.ID, clone, "ip") + err := repo.Update(clone.ID, &clone, "ip") - if player.UserId == "" { + if clone.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 { @@ -242,7 +225,7 @@ var _ = Describe("PlayerRepository", func() { } clone.MaxBitRate = player.MaxBitRate - newItem, err := repo.Get(repoCtx, clone.ID) + newItem, err := repo.Get(clone.ID) if player.UserId == "" { Expect(err).To(Equal(model.ErrNotFound)) @@ -261,361 +244,4 @@ var _ = Describe("PlayerRepository", func() { Entry("admin context", true, players, adminPlayer1, regularPlayer), Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1), ) - - Describe("API keys", func() { - const key = testAPIKey - const otherKey = "nds_ABCDEFGHIJKLMNOPQRSTUV" - var ownerCtx, otherCtx context.Context - - BeforeEach(func() { - ownerCtx = request.WithUser(log.NewContext(GinkgoT().Context()), regularUser) - otherCtx = request.WithUser(log.NewContext(GinkgoT().Context()), thirdUser) - }) - - storedHash := func(id string) string { - var row struct { - Hash string `db:"api_key_hash"` - } - Expect(database.NewQuery("select coalesce(api_key_hash, '') as api_key_hash from player where id = {:id}"). - Bind(dbx.Params{"id": id}).One(&row)).To(Succeed()) - return row.Hash - } - - Describe("SetAPIKey", func() { - It("stores only the hash and finds the player by the key", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - - Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) - plr, err := adminRepo.FindByAPIKey(ctx, key) - Expect(err).ToNot(HaveOccurred()) - Expect(plr.ID).To(Equal(regularPlayer.ID)) - Expect(plr.HasAPIKey).To(BeTrue()) - Expect(plr.APIKey).To(BeNil()) - }) - - It("replaces the previous key", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, otherKey)).To(Succeed()) - - _, err := adminRepo.FindByAPIKey(ctx, key) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = adminRepo.FindByAPIKey(ctx, otherKey) - Expect(err).ToNot(HaveOccurred()) - }) - - DescribeTable("rejects malformed keys", - func(bad string) { - err := adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, bad) - expectAPIKeyError(err, "resources.player.validation.apiKeyFormat") - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }, - Entry("no prefix", "0123456789abcdefghijklmn"), - Entry("too short", "nds_short"), - Entry("too long", key+"x"), - Entry("bad chars", "nds_0123456789abcdefghij-!"), - ) - - It("revokes with an empty key, by the owner or an admin", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, "")).To(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }) - - It("accepts revoking a player that has no key", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed()) - }) - - It("does not let an admin set a key on another user's player", func() { - Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, key)).To(MatchError(rest.ErrPermissionDenied)) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }) - - It("does not let another user set or revoke", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, otherKey)).To(MatchError(rest.ErrPermissionDenied)) - Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, "")).To(MatchError(rest.ErrPermissionDenied)) - Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) - }) - - It("returns not found for a missing player", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, "missing", key)).To(MatchError(rest.ErrNotFound)) - Expect(adminRepo.SetAPIKey(ownerCtx, "missing", "")).To(MatchError(rest.ErrNotFound)) - }) - - It("does not find unknown or empty keys", func() { - _, err := adminRepo.FindByAPIKey(ctx, otherKey) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = adminRepo.FindByAPIKey(ctx, "") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("drops the key with the player", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - Expect(adminRepo.Delete(ownerCtx, regularPlayer.ID)).To(Succeed()) - _, err := adminRepo.FindByAPIKey(ctx, key) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("hasApiKey filter", func() { - BeforeEach(func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - }) - - filtered := func(value string) []string { - res, err := adminRepo.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": value}}) - Expect(err).ToNot(HaveOccurred()) - var ids []string - for _, p := range res { - ids = append(ids, p.ID) - } - return ids - } - - It("lists only players with a key", func() { - Expect(filtered("true")).To(ConsistOf(regularPlayer.ID)) - count, err := adminRepo.Count(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": "true"}}) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(1))) - }) - - It("lists only players without a key", func() { - Expect(filtered("false")).To(ConsistOf(adminPlayer1.ID, adminPlayer2.ID)) - }) - }) - - Describe("Save (create)", func() { - It("creates the player with the key, owned by the logged-in user", func() { - id, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Manual player", APIKey: new(key)}) - Expect(err).ToNot(HaveOccurred()) - - plr, err := adminRepo.FindByAPIKey(ctx, key) - Expect(err).ToNot(HaveOccurred()) - Expect(plr.ID).To(Equal(id)) - Expect(plr.UserId).To(Equal(regularUser.ID)) - }) - - It("requires a key", func() { - count, _ := adminRepo.CountAll(ctx) - _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "No key"}) - expectAPIKeyError(err, "ra.validation.required") - - _, err = adminRepo.Save(ownerCtx, &model.Player{Name: "Empty key", APIKey: new("")}) - expectAPIKeyError(err, "ra.validation.required") - Expect(adminRepo.CountAll(ctx)).To(Equal(count)) - }) - - It("rejects a malformed key without creating the player", func() { - count, _ := adminRepo.CountAll(ctx) - _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Bad", APIKey: new("nds_bad")}) - expectAPIKeyError(err, "resources.player.validation.apiKeyFormat") - Expect(adminRepo.CountAll(ctx)).To(Equal(count)) - }) - - It("does not let an admin create a keyed player for another user", func() { - count, _ := adminRepo.CountAll(ctx) - _, err := adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID, APIKey: new(key)}) - Expect(err).To(MatchError(rest.ErrPermissionDenied)) - _, err = adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID}) - Expect(err).To(MatchError(rest.ErrPermissionDenied)) - Expect(adminRepo.CountAll(ctx)).To(Equal(count)) - }) - - It("rejects a key already used by another player without creating the player", func() { - Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed()) - count, _ := adminRepo.CountAll(ctx) - _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Duplicate", APIKey: new(key)}) - expectAPIKeyError(err, "ra.validation.unique") - Expect(adminRepo.CountAll(ctx)).To(Equal(count)) - }) - }) - - Describe("Update (edit)", func() { - It("rolls back the key change when the rest of the edit fails", func() { - _, err := database.NewQuery(`create trigger fail_player_rename before update of name on player - when new.name = 'boom' begin select raise(abort, 'boom'); end`).Execute() - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - _, _ = database.NewQuery("drop trigger if exists fail_player_rename").Execute() - }) - - plr := regularPlayer - plr.Name = "boom" - plr.APIKey = new(key) - Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).ToNot(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }) - - It("keeps the key when apiKey is absent (a normal edit)", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - - plr := regularPlayer - plr.Name = "Renamed" - Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "hasApiKey")).To(Succeed()) - Expect(adminRepo.Update(ownerCtx, plr.ID, plr)).To(Succeed()) - - found, err := adminRepo.FindByAPIKey(ctx, key) - Expect(err).ToNot(HaveOccurred()) - Expect(found.Name).To(Equal("Renamed")) - }) - - It("sets a new key when apiKey has a value", func() { - plr := regularPlayer - plr.APIKey = new(key) - Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).To(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) - }) - - It("revokes the key when apiKey is empty", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - plr := regularPlayer - plr.APIKey = new("") - Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "apiKey")).To(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }) - - It("lets an admin edit another user's keyed player without touching the key", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - plr := regularPlayer - plr.MaxBitRate = 192 - Expect(adminRepo.Update(ctx, plr.ID, plr, "maxBitRate", "hasApiKey")).To(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) - }) - - It("refuses an admin setting a key on another user's player and leaves other columns alone", func() { - plr := regularPlayer - plr.Name = "Hijacked" - plr.APIKey = new(key) - Expect(adminRepo.Update(ctx, plr.ID, plr, "name", "apiKey")).To(MatchError(rest.ErrPermissionDenied)) - - got, err := adminRepo.Get(ctx, regularPlayer.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Name).To(Equal(regularPlayer.Name)) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }) - - It("refuses a key already used by another player and leaves other columns alone", func() { - Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed()) - plr := regularPlayer - plr.Name = "Renamed" - plr.APIKey = new(key) - err := adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey") - expectAPIKeyError(err, "ra.validation.unique") - - got, err := adminRepo.Get(ctx, regularPlayer.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Name).To(Equal(regularPlayer.Name)) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - Expect(storedHash(adminPlayer1.ID)).To(Equal(hashAPIKey(key))) - }) - }) - }) - - Describe("Ownership enforcement (cross-tenant write protection)", func() { - var regularRepo *playerRepository - var regularCtx context.Context - - BeforeEach(func() { - regularCtx = request.WithUser(ctx, regularUser) - regularRepo = NewPlayerRepository(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(regularCtx, 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(ctx, adminPlayer1.ID) - Expect(err).To(BeNil()) - Expect(*stored).To(Equal(adminPlayer1)) - }) - - It("does not let a regular user overwrite another user's player via Save with a spoofed id", func() { - spoofed := model.Player{ - ID: adminPlayer1.ID, - Name: "HIJACKED", - UserId: regularUser.ID, - ReportRealPath: true, - APIKey: new(testAPIKey), - } - - id, err := regularRepo.Save(regularCtx, &spoofed) - Expect(err).To(BeNil()) - Expect(id).ToNot(Equal(adminPlayer1.ID)) - - stored, err := adminRepo.Get(ctx, adminPlayer1.ID) - Expect(err).To(BeNil()) - Expect(*stored).To(Equal(adminPlayer1)) - - created, err := adminRepo.Get(ctx, id) - Expect(err).To(BeNil()) - Expect(created.UserId).To(Equal(regularUser.ID)) - }) - - 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(regularCtx, regularPlayer.ID, reassign, "name", "user_id") - Expect(err).To(BeNil()) - - // Ownership must not have changed. - stored, err := adminRepo.Get(ctx, 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(regularCtx, regularPlayer.ID, reassign, "name", "user_id") - Expect(err).To(BeNil()) - - // The name change applies, but ownership must not have moved. - stored, err := adminRepo.Get(ctx, 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(regularCtx, regularPlayer.ID, update, "name") - Expect(err).To(BeNil()) - - stored, err := adminRepo.Get(ctx, 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(regularCtx, "does-not-exist", ghost, "name") - Expect(err).To(Equal(rest.ErrNotFound)) - }) - }) }) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 41b75266c..4152505d2 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -13,7 +13,6 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -40,28 +39,22 @@ func (p dbPlaylist) PostMapArgs(args map[string]any) error { if err != nil { return fmt.Errorf("invalid criteria expression: %w", err) } - // Smart playlist counters are owned by refreshCounters (evaluation), never by callers - delete(args, "song_count") - delete(args, "duration") - delete(args, "size") return nil } delete(args, "rules") return nil } -func NewPlaylistRepository(db dbx.Builder) model.PlaylistRepository { +func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRepository { r := &playlistRepository{} + r.ctx = ctx r.db = db r.registerModel(&model.Playlist{}, map[string]filterFunc{ - "id": idFilter("playlist"), - "q": playlistFilter, - "smart": smartPlaylistFilter, - "starred": annotationBoolFilter("starred"), + "q": playlistFilter, + "smart": smartPlaylistFilter, }) r.setSortMappings(map[string]string{ - "name": naturalSort("playlist.name"), - "owner_name": naturalSort("owner_name"), + "owner_name": "owner_name", }) return r } @@ -80,8 +73,8 @@ func smartPlaylistFilter(string, any) Sqlizer { } } -func (r *playlistRepository) userFilter(ctx context.Context) Sqlizer { - user := loggedUser(ctx) +func (r *playlistRepository) userFilter() Sqlizer { + user := loggedUser(r.ctx) if user.IsAdmin { return And{} } @@ -91,38 +84,34 @@ func (r *playlistRepository) userFilter(ctx context.Context) Sqlizer { } } -func (r *playlistRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - query := Select().Where(r.userFilter(ctx)) - if filtersNeedAnnotation(r.applyFilters(query, options...)) { - query = r.withAnnotation(ctx, query, "playlist.id") - } - return r.count(ctx, query, options...) +func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) { + sq := Select().Where(r.userFilter()) + return r.count(sq, options...) } -func (r *playlistRepository) Exists(ctx context.Context, id string) (bool, error) { - return r.exists(ctx, And{Eq{"id": id}, r.userFilter(ctx)}) +func (r *playlistRepository) Exists(id string) (bool, error) { + return r.exists(And{Eq{"id": id}, r.userFilter()}) } -func (r *playlistRepository) Delete(ctx context.Context, ids ...string) error { - return r.delete(ctx, And{Eq{"id": ids}, r.userFilter(ctx)}) +func (r *playlistRepository) Delete(id string) error { + return r.delete(And{Eq{"id": id}, r.userFilter()}) } -func (r *playlistRepository) Put(ctx context.Context, p *model.Playlist, cols ...string) error { +func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { pls := dbPlaylist{Playlist: *p} if len(cols) > 0 { if pls.ID == "" { return errors.New("playlist id is required for partial update") } - _, err := r.put(ctx, pls.ID, pls, cols...) + _, err := r.put(pls.ID, pls, cols...) return err } - isNew := pls.ID == "" - if isNew { + if pls.ID == "" { pls.CreatedAt = time.Now() } pls.UpdatedAt = time.Now() - id, err := r.put(ctx, pls.ID, pls) + id, err := r.put(pls.ID, pls) if err != nil { return err } @@ -134,48 +123,42 @@ func (r *playlistRepository) Put(ctx context.Context, p *model.Playlist, cols .. } // Only update tracks if they were specified if len(pls.Tracks) > 0 { - return r.updateTracks(ctx, id, p.MediaFiles()) + return r.updateTracks(id, p.MediaFiles()) } - pls.ID = id // r.put assigns the generated id to p, not to this copy - if isNew { - // Even a trackless new playlist has art to find (an imported m3u can carry an - // ExternalImageURL); an update landing here changed only metadata, so leave its cover be. - r.enqueueCoverRebuild(ctx, id) - } - return r.refreshCounters(ctx, &pls.Playlist) + return r.refreshCounters(&pls.Playlist) } -func (r *playlistRepository) Get(ctx context.Context, id string) (*model.Playlist, error) { - return r.findBy(ctx, And{Eq{"playlist.id": id}, r.userFilter(ctx)}) +func (r *playlistRepository) Get(id string) (*model.Playlist, error) { + return r.findBy(And{Eq{"playlist.id": id}, r.userFilter()}) } -func (r *playlistRepository) GetWithTracks(ctx context.Context, id string, refreshSmartPlaylist, includeMissing bool) (*model.Playlist, error) { - pls, err := r.Get(ctx, id) +func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*model.Playlist, error) { + pls, err := r.Get(id) if err != nil { return nil, err } if refreshSmartPlaylist { - r.refreshSmartPlaylist(ctx, pls) + r.refreshSmartPlaylist(pls) } - tracks, err := r.loadTracks(ctx, Select().From("playlist_tracks"). + tracks, err := r.loadTracks(Select().From("playlist_tracks"). Where(Eq{"missing": false}). OrderBy("playlist_tracks.id"), id) if err != nil { - log.Error(ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err) + log.Error(r.ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err) return nil, err } pls.SetTracks(tracks) return pls, nil } -func (r *playlistRepository) FindByPath(ctx context.Context, path string) (*model.Playlist, error) { - return r.findBy(ctx, Eq{"path": path}) +func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) { + return r.findBy(Eq{"path": path}) } -func (r *playlistRepository) findBy(ctx context.Context, sql Sqlizer) (*model.Playlist, error) { - sel := r.selectPlaylist(ctx).Where(sql) +func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) { + sel := r.selectPlaylist().Where(sql) var pls []dbPlaylist - err := r.queryAll(ctx, sel, &pls) + err := r.queryAll(sel, &pls) if err != nil { return nil, err } @@ -183,20 +166,13 @@ func (r *playlistRepository) findBy(ctx context.Context, sql Sqlizer) (*model.Pl return nil, model.ErrNotFound } - list := model.Playlists{pls[0].Playlist} - r.hydrateArtwork(ctx, list) - return &list[0], nil + return &pls[0].Playlist, nil } -func (r *playlistRepository) hydrateArtwork(ctx context.Context, playlists model.Playlists) { - hydrateItems(ctx, r.db, model.KindPlaylistArtwork, playlists, - func(p *model.Playlist) (string, *model.ItemImage) { return p.ID, &p.ItemImage }) -} - -func (r *playlistRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) { - sel := r.selectPlaylist(ctx, options...).Where(r.userFilter(ctx)) +func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) { + sel := r.selectPlaylist(options...).Where(r.userFilter()) var res []dbPlaylist - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) if err != nil { return nil, err } @@ -204,42 +180,15 @@ func (r *playlistRepository) GetAll(ctx context.Context, options ...model.QueryO for i, p := range res { playlists[i] = p.Playlist } - r.hydrateArtwork(ctx, playlists) return playlists, err } -// getAllIDs returns the IDs of GetAll's row set, skipping its per-row processing. -func (r *playlistRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - // Joins a projection of user, not the table: its name/created_at columns would make an ORDER BY - // on the playlist's own ambiguous. - sq := r.newSelect(ctx, options...).Columns("playlist.id", "user.user_name as owner_name"). - Join("(select id, user_name from user) user on user.id = owner_id").Where(r.userFilter(ctx)) - if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(ctx, sq, "playlist.id") - } - ids := []string{} - err := r.queryAllSlice(ctx, sq, &ids) - return ids, err -} - -func (r *playlistRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.PlaylistCursor, error) { - // Both passes apply userFilter, so a visibility change between them cannot widen the cursor. - ids, err := r.getAllIDs(ctx, options...) - if err != nil { - return nil, err - } - opts := chunkOptions(options, "playlist.id") - return model.PlaylistCursor(streamByIDs(ids, func(chunk []string) (model.Playlists, error) { - return r.GetAll(ctx, opts(chunk)) - })), nil -} - -func (r *playlistRepository) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) { - sel := r.selectPlaylist(ctx, model.QueryOptions{Sort: "name"}). +func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) { + sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}). Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id"). - Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter(ctx)}) + Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter()}) var res []dbPlaylist - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) if err != nil { if errors.Is(err, model.ErrNotFound) { return model.Playlists{}, nil @@ -250,43 +199,34 @@ func (r *playlistRepository) GetPlaylists(ctx context.Context, mediaFileId strin for i, p := range res { playlists[i] = p.Playlist } - r.hydrateArtwork(ctx, playlists) return playlists, nil } -func (r *playlistRepository) selectPlaylist(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - sel := r.newSelect(ctx, options...).Join("user on user.id = owner_id"). +func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder { + return r.newSelect(options...).Join("user on user.id = owner_id"). Columns(r.tableName+".*", "user.user_name as owner_name") - return r.withAnnotation(ctx, sel, r.tableName+".id") } -func (r *playlistRepository) updateTracks(ctx context.Context, id string, tracks model.MediaFiles) error { +func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { ids := make([]string, len(tracks)) for i := range tracks { ids[i] = tracks[i].ID } - return r.updatePlaylist(ctx, id, ids) + return r.updatePlaylist(id, ids) } -func (r *playlistRepository) updatePlaylist(ctx context.Context, playlistId string, mediaFileIds []string) error { +func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error { // Remove old tracks del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId}) - _, err := r.executeSQL(ctx, del) + _, err := r.executeSQL(del) if err != nil { return err } - _, err = r.addTracks(ctx, playlistId, 1, mediaFileIds) - return err + return r.addTracks(playlistId, 1, mediaFileIds) } -// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns -// the library check: every caller, including a full replace through Put, goes through it. -func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, startingPos int, mediaFileIds []string) (int, error) { - mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds) - if err != nil { - return 0, err - } +func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error { // Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit // Add new tracks, chunk by chunk pos := startingPos @@ -296,40 +236,17 @@ func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, s ins = ins.Values(playlistId, t, pos) pos++ } - if _, err := r.executeSQL(ctx, ins); err != nil { - return 0, err + _, err := r.executeSQL(ins) + if err != nil { + return err } } - r.enqueueCoverRebuild(ctx, playlistId) - return len(mediaFileIds), r.refreshCounters(ctx, &model.Playlist{ID: playlistId}) -} - -// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked -// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER. -func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) { - if visible, err := r.visibleLibraryIDs(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) { - return mediaFileIds, nil - } - accessible := make(map[string]struct{}, len(mediaFileIds)) - for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) { - sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file") - var found []string - if err := r.queryAllSlice(ctx, sq, &found); err != nil { - return nil, err - } - for _, id := range found { - accessible[id] = struct{}{} - } - } - return slice.Filter(mediaFileIds, func(id string) bool { - _, ok := accessible[id] - return ok - }), nil + return r.refreshCounters(&model.Playlist{ID: playlistId}) } // refreshCounters updates total playlist duration, size and count -func (r *playlistRepository) refreshCounters(ctx context.Context, pls *model.Playlist) error { +func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { statsSql := Select( "coalesce(sum(duration), 0) as duration", "coalesce(sum(size), 0) as size", @@ -339,45 +256,32 @@ func (r *playlistRepository) refreshCounters(ctx context.Context, pls *model.Pla Join("playlist_tracks f on f.media_file_id = media_file.id"). Where(Eq{"playlist_id": pls.ID}) var res struct{ Duration, Size, Count float32 } - err := r.queryOne(ctx, statsSql, &res) + err := r.queryOne(statsSql, &res) if err != nil { return err } // Update playlist's total duration, size and count - now := time.Now() upd := Update("playlist"). Set("duration", res.Duration). Set("size", res.Size). Set("song_count", res.Count). - Set("updated_at", now). + Set("updated_at", time.Now()). Where(Eq{"id": pls.ID}) - _, err = r.executeSQL(ctx, upd) + _, err = r.executeSQL(upd) if err != nil { return err } pls.SongCount = int(res.Count) pls.Duration = res.Duration pls.Size = int64(res.Size) - pls.UpdatedAt = now return nil } -// enqueueCoverRebuild re-resolves the generated 2x2 grid. Call it only when the track set changes: -// the grid samples albums at random, so rebuilding after a mere rename would change the cover. -func (r *playlistRepository) enqueueCoverRebuild(ctx context.Context, id string) { - item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: id, - ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityScan} - if err := NewArtworkQueueRepository(r.db).Enqueue(ctx, item); err != nil { - log.Warn(ctx, "could not enqueue playlist artwork after content change", "id", id, err) - } -} - -// tracksQuery is shared by loadTracks and GetCursor, so both hydrate rows identically. -func (r *playlistRepository) tracksQuery(ctx context.Context, query SelectBuilder, id string) SelectBuilder { - query = r.applyLibraryFilter(ctx, query, "f") - userID := loggedUser(ctx).ID - return query. +func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) { + sel = r.applyLibraryFilter(sel, "f") + userID := loggedUser(r.ctx).ID + tracksQuery := sel. Columns( "coalesce(starred, 0) as starred", "starred_at", @@ -397,49 +301,56 @@ func (r *playlistRepository) tracksQuery(ctx context.Context, query SelectBuilde Join("media_file f on f.id = media_file_id"). Join("library on f.library_id = library.id"). Where(Eq{"playlist_id": id}) -} - -func (r *playlistRepository) loadTracks(ctx context.Context, query SelectBuilder, id string) (model.PlaylistTracks, error) { tracks := dbPlaylistTracks{} - err := r.queryAll(ctx, r.tracksQuery(ctx, query, id), &tracks) + err := r.queryAll(tracksQuery, &tracks) if err != nil { return nil, err } - res := tracks.toModels() - hydratePlaylistTrackArtwork(ctx, r.db, res) - return res, err + return tracks.toModels(), err } -func (r *playlistRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *playlistRepository) Read(ctx context.Context, id string) (*model.Playlist, error) { - return r.Get(ctx, id) +func (r *playlistRepository) Read(id string) (any, error) { + return r.Get(id) } -func (r *playlistRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Playlist, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *playlistRepository) Save(ctx context.Context, pls *model.Playlist) (string, error) { +func (r *playlistRepository) EntityName() string { + return "playlist" +} + +func (r *playlistRepository) NewInstance() any { + return &model.Playlist{} +} + +func (r *playlistRepository) Save(entity any) (string, error) { + pls := entity.(*model.Playlist) pls.ID = "" // Force new creation - err := r.Put(ctx, pls) + err := r.Put(pls) if err != nil { return "", err } return pls.ID, err } -func (r *playlistRepository) Update(ctx context.Context, id string, entity model.Playlist, cols ...string) error { - pls := dbPlaylist{Playlist: entity} +func (r *playlistRepository) Update(id string, entity any, cols ...string) error { + pls := dbPlaylist{Playlist: *entity.(*model.Playlist)} pls.ID = id pls.UpdatedAt = time.Now() - _, err := r.put(ctx, id, pls, append(cols, "updatedAt")...) + _, err := r.put(id, pls, append(cols, "updatedAt")...) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } return err } -func (r *playlistRepository) removeOrphans(ctx context.Context) error { +func (r *playlistRepository) removeOrphans() error { sel := Select("playlist_tracks.playlist_id as id", "p.name").From("playlist_tracks"). Join("playlist p on playlist_tracks.playlist_id = p.id"). LeftJoin("media_file mf on playlist_tracks.media_file_id = mf.id"). @@ -447,25 +358,25 @@ func (r *playlistRepository) removeOrphans(ctx context.Context) error { GroupBy("playlist_tracks.playlist_id") var pls []struct{ Id, Name string } - err := r.queryAll(ctx, sel, &pls) + err := r.queryAll(sel, &pls) if err != nil { return fmt.Errorf("fetching playlists with orphan tracks: %w", err) } for _, pl := range pls { - log.Debug(ctx, "Cleaning-up orphan tracks from playlist", "id", pl.Id, "name", pl.Name) + log.Debug(r.ctx, "Cleaning-up orphan tracks from playlist", "id", pl.Id, "name", pl.Name) del := Delete("playlist_tracks").Where(And{ ConcatExpr("media_file_id not in (select id from media_file)"), Eq{"playlist_id": pl.Id}, }) - n, err := r.executeSQL(ctx, del) + n, err := r.executeSQL(del) if n == 0 || err != nil { return fmt.Errorf("deleting orphan tracks from playlist %s: %w", pl.Name, err) } - log.Debug(ctx, "Deleted tracks, now reordering", "id", pl.Id, "name", pl.Name, "deleted", n) + log.Debug(r.ctx, "Deleted tracks, now reordering", "id", pl.Id, "name", pl.Name, "deleted", n) // Renumber the playlist if any track was removed - if err := r.renumber(ctx, pl.Id); err != nil { + if err := r.renumber(pl.Id); err != nil { return fmt.Errorf("renumbering playlist %s: %w", pl.Name, err) } } @@ -475,9 +386,9 @@ func (r *playlistRepository) removeOrphans(ctx context.Context) error { // renumber updates the position of all tracks in the playlist to be sequential starting from 1, ordered by their // current position. This is needed after removing orphan tracks, to ensure there are no gaps in the track numbering. // The two-step approach (negate then reassign via CTE) avoids UNIQUE constraint violations on (playlist_id, id). -func (r *playlistRepository) renumber(ctx context.Context, id string) error { +func (r *playlistRepository) renumber(id string) error { // Step 1: Negate all IDs to clear the positive ID space - _, err := r.executeSQL(ctx, Expr( + _, err := r.executeSQL(Expr( `UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id)) if err != nil { return err @@ -485,7 +396,7 @@ func (r *playlistRepository) renumber(ctx context.Context, id string) error { // Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE. // The CTE is fully materialized before the UPDATE begins, avoiding self-referencing issues. // ORDER BY id DESC restores original order since IDs are now negative. - _, err = r.executeSQL(ctx, Expr( + _, err = r.executeSQL(Expr( `WITH new_ids AS ( SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id FROM playlist_tracks WHERE playlist_id = ? @@ -496,10 +407,9 @@ func (r *playlistRepository) renumber(ctx context.Context, id string) error { if err != nil { return err } - r.enqueueCoverRebuild(ctx, id) - return r.refreshCounters(ctx, &model.Playlist{ID: id}) + return r.refreshCounters(&model.Playlist{ID: id}) } var _ model.PlaylistRepository = (*playlistRepository)(nil) -var _ rest.Repository[model.Playlist] = (*playlistRepository)(nil) -var _ rest.Persistable[model.Playlist] = (*playlistRepository)(nil) +var _ rest.Repository = (*playlistRepository)(nil) +var _ rest.Persistable = (*playlistRepository)(nil) diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 93d37c928..cfabd0983 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,103 +1,40 @@ package persistence import ( - "context" - "slices" - - "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" ) var _ = Describe("PlaylistRepository", func() { var repo model.PlaylistRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(GetDBXBuilder()) - }) - - Describe("natural sorting", func() { - var ids []string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableNaturalSorting = true - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(GetDBXBuilder()) - - ids = nil - for _, n := range []string{"mix 1", "mix 10", "mix 2"} { - pls := model.Playlist{Name: n, OwnerID: "userid"} - Expect(repo.Put(ctx, &pls)).To(Succeed()) - ids = append(ids, pls.ID) - } - DeferCleanup(func() { - for _, id := range ids { - _ = repo.Delete(ctx, id) - } - }) - }) - - It("sorts playlist names by number value", func() { - all, err := repo.GetAll(ctx, model.QueryOptions{ - Sort: "name", Filters: squirrel.Eq{"playlist.id": ids}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(all, func(p model.Playlist) string { return p.Name })).To( - Equal([]string{"mix 1", "mix 2", "mix 10"})) - }) + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()) }) Describe("Count", func() { It("returns the number of playlists in the DB", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) - }) - }) - - Describe("GetCursor", func() { - It("yields the same playlists as GetAll", func() { - opts := model.QueryOptions{Sort: "name"} - want, err := repo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.Playlist(want))) - }) - }) - - Describe("getAllIDs", func() { - It("returns the same id set as GetAll", func() { - want, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - ids, err := repo.(*playlistRepository).getAllIDs(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) + Expect(repo.CountAll()).To(Equal(int64(2))) }) }) Describe("Exists", func() { It("returns true for an existing playlist", func() { - Expect(repo.Exists(ctx, plsCool.ID)).To(BeTrue()) + Expect(repo.Exists(plsCool.ID)).To(BeTrue()) }) It("returns false for a non-existing playlist", func() { - Expect(repo.Exists(ctx, "666")).To(BeFalse()) + Expect(repo.Exists("666")).To(BeFalse()) }) }) Describe("Get", func() { It("returns an existing playlist", func() { - p, err := repo.Get(ctx, plsBest.ID) + p, err := repo.Get(plsBest.ID) Expect(err).To(BeNil()) // Compare all but Tracks and timestamps p2 := *p @@ -111,11 +48,11 @@ var _ = Describe("PlaylistRepository", func() { } }) It("returns ErrNotFound for a non-existing playlist", func() { - _, err := repo.Get(ctx, "666") + _, err := repo.Get("666") Expect(err).To(MatchError(model.ErrNotFound)) }) It("returns all tracks", func() { - pls, err := repo.GetWithTracks(ctx, plsBest.ID, true, false) + pls, err := repo.GetWithTracks(plsBest.ID, true, false) Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal(plsBest.Name)) Expect(pls.Tracks).To(HaveLen(2)) @@ -134,247 +71,36 @@ var _ = Describe("PlaylistRepository", func() { }) }) - Describe("Annotations", func() { - var plsID string - - BeforeEach(func() { - pls := model.Playlist{Name: "Annotated", OwnerID: "userid"} - Expect(repo.Put(ctx, &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(ctx, true, plsID)).To(Succeed()) - - p, err := repo.Get(ctx, 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(ctx, 4, plsID)).To(Succeed()) - - p, err := repo.Get(ctx, 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(ctx, true, plsID)).To(Succeed()) - - otherCtx := request.WithUser(log.NewContext(GinkgoT().Context()), - model.User{ID: "otheruser", UserName: "otheruser", IsAdmin: true}) - otherRepo := NewPlaylistRepository(GetDBXBuilder()) - - p, err := otherRepo.Get(otherCtx, plsID) - Expect(err).ToNot(HaveOccurred()) - Expect(p.Starred).To(BeFalse()) - }) - - It("reads starred back through GetAll", func() { - Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - idx := slices.IndexFunc(all, func(p model.Playlist) bool { return p.ID == plsID }) - Expect(idx).To(BeNumerically(">=", 0)) - Expect(all[idx].Starred).To(BeTrue()) - }) - - It("counts playlists using annotation filters", func() { - Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - - options := model.QueryOptions{Filters: squirrel.Eq{"starred": true}} - starred, err := repo.GetAll(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(starred).To(ContainElement(HaveField("ID", plsID))) - - count, err := repo.CountAll(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(len(starred)))) - }) - - It("filters starred playlists through the registered REST filter", func() { - Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - - res, err := repo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"starred": "true"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(ContainElement(HaveField("ID", plsID))) - for _, p := range res { - Expect(p.Starred).To(BeTrue()) - } - - res, err = repo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"starred": "false"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(res).ToNot(ContainElement(HaveField("ID", plsID))) - }) - - It("reads a playlist by id through the REST id filter without ambiguity", func() { - res, err := repo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"id": plsID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(ContainElement(HaveField("ID", plsID))) - }) - - It("does not leak an annotation row of another item_type sharing the playlist id", func() { - // Older builds (and the star fallthrough) can leave a media_file-typed row - // under a playlist id; the item_type-scoped join must not surface or dupe it. - _, err := GetDBXBuilder().NewQuery( - "INSERT INTO annotation (user_id, item_id, item_type, starred) VALUES ({:uid}, {:id}, 'media_file', 1)"). - Bind(dbx.Params{"uid": "userid", "id": plsID}).Execute() - Expect(err).ToNot(HaveOccurred()) - - p, err := repo.Get(ctx, plsID) - Expect(err).ToNot(HaveOccurred()) - Expect(p.Starred).To(BeFalse()) - - all, err := repo.GetAll(ctx) - 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(ctx, true, plsID)).To(Succeed()) - - Expect(repo.Delete(ctx, plsID)).To(Succeed()) - Expect(countAnnotations()).To(Equal(1)) - - Expect(repo.(*playlistRepository).cleanAnnotations(ctx)).To(Succeed()) - Expect(countAnnotations()).To(Equal(0)) - }) - }) - - Describe("Put", func() { - It("does not overwrite counters when saving a smart playlist", func() { - pls := model.Playlist{Name: "Smart Counters", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "love"}}, - }} - Expect(repo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { Expect(repo.Delete(ctx, pls.ID)).To(Succeed()) }) - - // Simulate a previous evaluation having stored the counters - _, err := GetDBXBuilder().NewQuery("update playlist set song_count = 42, duration = 123, size = 456 where id = {:id}"). - Bind(dbx.Params{"id": pls.ID}).Execute() - Expect(err).ToNot(HaveOccurred()) - - pls.SongCount = 0 - pls.Duration = 0 - pls.Size = 0 - Expect(repo.Put(ctx, &pls)).To(Succeed()) - - saved, err := repo.Get(ctx, pls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(saved.SongCount).To(Equal(42)) - Expect(saved.Duration).To(Equal(float32(123))) - Expect(saved.Size).To(Equal(int64(456))) - }) - }) - It("Put/Exists/Delete", func() { By("saves the playlist to the DB") newPls := model.Playlist{Name: "Great!", OwnerID: "userid"} newPls.AddMediaFilesByID([]string{"1004", "1003"}) By("saves the playlist to the DB") - Expect(repo.Put(ctx, &newPls)).To(BeNil()) + Expect(repo.Put(&newPls)).To(BeNil()) By("adds repeated songs to a playlist and keeps the order") newPls.AddMediaFilesByID([]string{"1004"}) - Expect(repo.Put(ctx, &newPls)).To(BeNil()) - saved, _ := repo.GetWithTracks(ctx, newPls.ID, true, false) + Expect(repo.Put(&newPls)).To(BeNil()) + saved, _ := repo.GetWithTracks(newPls.ID, true, false) Expect(saved.Tracks).To(HaveLen(3)) Expect(saved.Tracks[0].MediaFileID).To(Equal("1004")) Expect(saved.Tracks[1].MediaFileID).To(Equal("1003")) Expect(saved.Tracks[2].MediaFileID).To(Equal("1004")) By("returns the newly created playlist") - Expect(repo.Exists(ctx, newPls.ID)).To(BeTrue()) + Expect(repo.Exists(newPls.ID)).To(BeTrue()) By("returns deletes the playlist") - Expect(repo.Delete(ctx, newPls.ID)).To(BeNil()) + Expect(repo.Delete(newPls.ID)).To(BeNil()) By("returns error if tries to retrieve the deleted playlist") - Expect(repo.Exists(ctx, newPls.ID)).To(BeFalse()) - }) - - It("enqueues a new empty playlist's artwork under its generated id, not an empty id", func() { - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - newPls := model.Playlist{Name: "Empty PL", OwnerID: "userid"} // no tracks → refreshCounters path - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - Expect(newPls.ID).ToNot(BeEmpty()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - - queued, err := NewArtworkQueueRepository(GetDBXBuilder()).DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll(HaveField("ItemKind", "pl"), HaveField("ItemID", newPls.ID)))) - Expect(queued).ToNot(ContainElement(HaveField("ItemID", "")), "must not enqueue an empty playlist id") - }) - - // The grid samples albums at random, so re-resolving after a rename would change the cover. - It("does not enqueue artwork when only metadata changes", func() { - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - newPls := model.Playlist{Name: "Rename Me", OwnerID: "userid"} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - // Clear the row creation just enqueued, so anything present afterwards came from the update. - queueRepo := NewArtworkQueueRepository(GetDBXBuilder()) - queued, err := queueRepo.DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - for _, q := range queued { - if q.ItemID == newPls.ID { - Expect(queueRepo.DeleteIfUnchanged(ctx, q.ItemKind, q.ItemID, q.ImageType, q.RetryAt)).To(Succeed()) - } - } - - newPls.Name = "Renamed" - newPls.Comment = "edited" - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - - queued, err = queueRepo.DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).ToNot(ContainElement(HaveField("ItemID", newPls.ID))) - }) - - It("enqueues the playlist's artwork when its track set changes", func() { - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - newPls := model.Playlist{Name: "Grid PL", OwnerID: "userid"} - newPls.AddMediaFilesByID([]string{"1001", "1002"}) - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - - queued, err := NewArtworkQueueRepository(GetDBXBuilder()).DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "pl"), - HaveField("ItemID", newPls.ID), - ))) + Expect(repo.Exists(newPls.ID)).To(BeFalse()) }) Describe("GetAll", func() { It("returns all playlists from DB", func() { - all, err := repo.GetAll(ctx) + all, err := repo.GetAll() Expect(err).To(BeNil()) Expect(all[0].ID).To(Equal(plsBest.ID)) Expect(all[1].ID).To(Equal(plsCool.ID)) @@ -383,14 +109,14 @@ var _ = Describe("PlaylistRepository", func() { Describe("GetPlaylists", func() { It("returns playlists for a track", func() { - pls, err := repo.GetPlaylists(ctx, songRadioactivity.ID) + pls, err := repo.GetPlaylists(songRadioactivity.ID) Expect(err).ToNot(HaveOccurred()) Expect(pls).To(HaveLen(1)) Expect(pls[0].ID).To(Equal(plsBest.ID)) }) It("returns empty when none", func() { - pls, err := repo.GetPlaylists(ctx, "9999") + pls, err := repo.GetPlaylists("9999") Expect(err).ToNot(HaveOccurred()) Expect(pls).To(HaveLen(0)) }) @@ -401,14 +127,14 @@ var _ = Describe("PlaylistRepository", func() { AfterEach(func() { if testPlaylistID != "" { - Expect(repo.Delete(ctx, testPlaylistID)).To(BeNil()) + Expect(repo.Delete(testPlaylistID)).To(BeNil()) testPlaylistID = "" } }) // helper to get track positions and media file IDs getTrackInfo := func(playlistID string) (ids []string, mediaFileIDs []string) { - pls, err := repo.GetWithTracks(ctx, playlistID, false, false) + pls, err := repo.GetWithTracks(playlistID, false, false) Expect(err).ToNot(HaveOccurred()) for _, t := range pls.Tracks { ids = append(ids, t.ID) @@ -421,12 +147,12 @@ var _ = Describe("PlaylistRepository", func() { By("creating a playlist with 4 tracks") newPls := model.Playlist{Name: "Renumber Test Middle", OwnerID: "userid"} newPls.AddMediaFilesByID([]string{"1001", "1002", "1003", "1004"}) - Expect(repo.Put(ctx, &newPls)).To(Succeed()) + Expect(repo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID By("deleting the second track (position 2)") - tracksRepo := repo.Tracks(ctx, newPls.ID, false) - Expect(tracksRepo.Delete(ctx, "2")).To(Succeed()) + tracksRepo := repo.Tracks(newPls.ID, false) + Expect(tracksRepo.Delete("2")).To(Succeed()) By("verifying remaining tracks are renumbered sequentially") ids, mediaFileIDs := getTrackInfo(newPls.ID) @@ -438,12 +164,12 @@ var _ = Describe("PlaylistRepository", func() { By("creating a playlist with 3 tracks") newPls := model.Playlist{Name: "Renumber Test First", OwnerID: "userid"} newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"}) - Expect(repo.Put(ctx, &newPls)).To(Succeed()) + Expect(repo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID By("deleting the first track (position 1)") - tracksRepo := repo.Tracks(ctx, newPls.ID, false) - Expect(tracksRepo.Delete(ctx, "1")).To(Succeed()) + tracksRepo := repo.Tracks(newPls.ID, false) + Expect(tracksRepo.Delete("1")).To(Succeed()) By("verifying remaining tracks are renumbered sequentially") ids, mediaFileIDs := getTrackInfo(newPls.ID) @@ -455,12 +181,12 @@ var _ = Describe("PlaylistRepository", func() { By("creating a playlist with 3 tracks") newPls := model.Playlist{Name: "Renumber Test Last", OwnerID: "userid"} newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"}) - Expect(repo.Put(ctx, &newPls)).To(Succeed()) + Expect(repo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID By("deleting the last track (position 3)") - tracksRepo := repo.Tracks(ctx, newPls.ID, false) - Expect(tracksRepo.Delete(ctx, "3")).To(Succeed()) + tracksRepo := repo.Tracks(newPls.ID, false) + Expect(tracksRepo.Delete("3")).To(Succeed()) By("verifying remaining tracks are renumbered sequentially") ids, mediaFileIDs := getTrackInfo(newPls.ID) @@ -468,27 +194,4 @@ var _ = Describe("PlaylistRepository", func() { Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"})) }) }) - - // Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist" - // -- the public image route serving a share -- must elevate, or a private playlist looks gone. - Describe("Exists visibility", func() { - It("hides a private playlist from an unauthenticated context", func() { - // "userid" is the fixture user; playlist.owner_id has a FK to user(id). - owner := model.User{ID: "userid", UserName: "userid"} - octx := request.WithUser(GinkgoT().Context(), owner) - ownerRepo := NewPlaylistRepository(GetDBXBuilder()) - pls := model.Playlist{Name: "Private One", OwnerID: owner.ID, Public: false} - Expect(ownerRepo.Put(octx, &pls)).To(Succeed()) - DeferCleanup(func() { _ = ownerRepo.Delete(octx, pls.ID) }) - - Expect(ownerRepo.Exists(octx, pls.ID)).To(BeTrue(), "the owner sees it") - - anon := NewPlaylistRepository(GetDBXBuilder()) - Expect(anon.Exists(GinkgoT().Context(), pls.ID)).To(BeFalse(), "no user: userFilter hides it") - - admin := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", IsAdmin: true}) - Expect(NewPlaylistRepository(GetDBXBuilder()).Exists(admin, pls.ID)).To(BeTrue(), - "elevating is what the public image route relies on") - }) - }) }) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 392446cef..1a7062cc2 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -1,9 +1,7 @@ package persistence import ( - "context" "database/sql" - "slices" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -41,10 +39,11 @@ func (t dbPlaylistTracks) toModels() model.PlaylistTracks { }) } -func (r *playlistRepository) Tracks(ctx context.Context, playlistId string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { +func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { p := &playlistTrackRepository{} p.playlistRepo = r p.playlistId = playlistId + p.ctx = r.ctx p.db = r.db p.tableName = "playlist_tracks" p.registerModel(&model.PlaylistTrack{}, map[string]filterFunc{ @@ -56,9 +55,8 @@ func (r *playlistRepository) Tracks(ctx context.Context, playlistId string, refr "id": "playlist_tracks.id", "artist": "order_artist_name", "album_artist": "order_album_artist_name", - "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("f.title"), + "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", "title": "order_title", - "random": "random()", // To make sure these fields will be whitelisted "duration": "duration", "year": "year", @@ -67,37 +65,28 @@ func (r *playlistRepository) Tracks(ctx context.Context, playlistId string, refr }, "f") // TODO I don't like this solution, but I won't change it now as it's not the focus of BFR. - pls, err := r.Get(ctx, playlistId) + pls, err := r.Get(playlistId) if err != nil { - log.Warn(ctx, "Error getting playlist's tracks", "playlistId", playlistId, err) + log.Warn(r.ctx, "Error getting playlist's tracks", "playlistId", playlistId, err) return nil } if refreshSmartPlaylist { - r.refreshSmartPlaylist(ctx, pls) + r.refreshSmartPlaylist(pls) } p.playlist = pls return p } -func (r *playlistTrackRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - query := Select(). - Join("media_file f on f.id = media_file_id"). - Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(ctx, query, "f") - return r.count(ctx, query, options...) -} - -func (r *playlistTrackRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { +func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) { query := Select(). LeftJoin("media_file f on f.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(ctx, query, "f") - return r.count(ctx, query, r.parseRestOptions(ctx, options...)) + return r.count(query, r.parseRestOptions(r.ctx, options...)) } -func (r *playlistTrackRepository) Read(ctx context.Context, id string) (*model.PlaylistTrack, error) { - userID := loggedUser(ctx).ID - sel := r.newSelect(ctx). +func (r *playlistTrackRepository) Read(id string) (any, error) { + userID := loggedUser(r.ctx).ID + sel := r.newSelect(). LeftJoin("annotation on ("+ "annotation.item_id = media_file_id"+ " AND annotation.item_type = 'media_file'"+ @@ -114,132 +103,81 @@ func (r *playlistTrackRepository) Read(ctx context.Context, id string) (*model.P ). Join("media_file f on f.id = media_file_id"). Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}}) - sel = r.applyLibraryFilter(ctx, sel, "f") var trk dbPlaylistTrack - err := r.queryOne(ctx, sel, &trk) + err := r.queryOne(sel, &trk) return trk.PlaylistTrack, err } -func (r *playlistTrackRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.PlaylistTracks, error) { - tracks, err := r.playlistRepo.loadTracks(ctx, r.newSelect(ctx, options...), r.playlistId) +func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) { + tracks, err := r.playlistRepo.loadTracks(r.newSelect(options...), r.playlistId) if err != nil { return nil, err } return tracks, err } -func (r *playlistTrackRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { - sel := r.playlistRepo.tracksQuery(ctx, r.newSelect(ctx, options...), r.playlistId) - cursor, err := queryWithStableResults[dbPlaylistTrack](ctx, r.sqlRepository, sel) - if err != nil { - return nil, err - } - tracks := wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack { - return t.PlaylistTrack - }) - return model.PlaylistTrackCursor(hydrateCursor(tracks, func(batch []model.PlaylistTrack) { - hydratePlaylistTrackArtwork(ctx, r.db, batch) - })), nil -} - -// GetMediaFileIDs returns the tracks' song ids, for callers that need every id but no track data. -func (r *playlistTrackRepository) GetMediaFileIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - query := r.newSelect(ctx, options...).Columns("media_file_id"). - Join("media_file f on f.id = media_file_id"). - Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(ctx, query, "f") - var ids []string - if err := r.queryAllSlice(ctx, query, &ids); err != nil { - return nil, err - } - return ids, nil -} - -func (r *playlistTrackRepository) GetAlbumIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - query := r.newSelect(ctx, options...).Columns("distinct mf.album_id"). +func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) { + query := r.newSelect(options...).Columns("distinct mf.album_id"). Join("media_file mf on mf.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(ctx, query, "mf") var ids []string - err := r.queryAllSlice(ctx, query, &ids) + err := r.queryAllSlice(query, &ids) if err != nil { return nil, err } return ids, nil } -func (r *playlistTrackRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.PlaylistTrack, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *playlistTrackRepository) Add(ctx context.Context, mediaFileIds []string) (int, error) { +func (r *playlistTrackRepository) EntityName() string { + return "playlist_tracks" +} + +func (r *playlistTrackRepository) NewInstance() any { + return &model.PlaylistTrack{} +} + +func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) { if len(mediaFileIds) > 0 { - log.Debug(ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds) + log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds) } else { return 0, nil } // Get next pos (ID) in playlist - sq := r.newSelect(ctx).Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}) + sq := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}) var res struct{ Max sql.NullInt32 } - if err := r.queryOne(ctx, sq, &res); err != nil { - return 0, err - } - - return r.playlistRepo.addTracks(ctx, r.playlistId, int(res.Max.Int32+1), mediaFileIds) -} - -// Insert adds tracks before the 1-based position pos, shifting the following entries down; a -// position past the end appends. Callers must run it in a transaction. -func (r *playlistTrackRepository) Insert(ctx context.Context, mediaFileIds []string, pos int) (int, error) { - if len(mediaFileIds) == 0 { - return 0, nil - } - pos = max(pos, 1) - n := len(mediaFileIds) - // Negate while shifting, so no intermediate row hits the unique (playlist_id, id) index. - _, err := r.executeSQL(ctx, Expr(`UPDATE playlist_tracks SET id = -(id + ?) WHERE playlist_id = ? AND id >= ?`, n, r.playlistId, pos)) + err := r.queryOne(sq, &res) if err != nil { return 0, err } - res, err := r.executeSQL(ctx, Expr(`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0`, r.playlistId)) - if err != nil { - return 0, err - } - if res == 0 { - return r.Add(ctx, mediaFileIds) - } - inserted, err := r.playlistRepo.addTracks(ctx, r.playlistId, pos, mediaFileIds) - if err != nil || inserted == n { - return inserted, err - } - // The shift above reserved a slot per requested id, so ids dropped by the library filter - // leave a hole. Close it. - return inserted, r.playlistRepo.renumber(ctx, r.playlistId) + + return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds) } -func (r *playlistTrackRepository) addMediaFileIds(ctx context.Context, cond Sqlizer) (int, error) { +func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) { sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, release_date, disc_number, track_number") var ids []string - err := r.queryAllSlice(ctx, sq, &ids) + err := r.queryAllSlice(sq, &ids) if err != nil { - log.Error(ctx, "Error getting tracks to add to playlist", err) + log.Error(r.ctx, "Error getting tracks to add to playlist", err) return 0, err } - return r.Add(ctx, ids) + return r.Add(ids) } -func (r *playlistTrackRepository) AddAlbums(ctx context.Context, albumIds []string) (int, error) { - return r.addMediaFileIds(ctx, Eq{"album_id": albumIds}) +func (r *playlistTrackRepository) AddAlbums(albumIds []string) (int, error) { + return r.addMediaFileIds(Eq{"album_id": albumIds}) } -func (r *playlistTrackRepository) AddArtists(ctx context.Context, artistIds []string) (int, error) { - // Match by album-artist participation, not the deprecated album_artist_id - // column, which only holds the first album artist. - return r.addMediaFileIds(ctx, ParticipantIDFilter("media_file", artistIds, model.RoleAlbumArtist)) +func (r *playlistTrackRepository) AddArtists(artistIds []string) (int, error) { + return r.addMediaFileIds(Eq{"album_artist_id": artistIds}) } -func (r *playlistTrackRepository) AddDiscs(ctx context.Context, discs []model.DiscID) (int, error) { +func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) { if len(discs) == 0 { return 0, nil } @@ -247,50 +185,36 @@ func (r *playlistTrackRepository) AddDiscs(ctx context.Context, discs []model.Di for _, d := range discs { clauses = append(clauses, And{Eq{"album_id": d.AlbumID}, Eq{"release_date": d.ReleaseDate}, Eq{"disc_number": d.DiscNumber}}) } - return r.addMediaFileIds(ctx, clauses) + return r.addMediaFileIds(clauses) } -// deleteChunkSize keeps each DELETE under SQLITE_MAX_VARIABLE_NUMBER, matching addTracks. -const deleteChunkSize = 200 - -func (r *playlistTrackRepository) Delete(ctx context.Context, ids ...string) error { - for chunk := range slices.Chunk(ids, deleteChunkSize) { - if err := r.delete(ctx, And{Eq{"playlist_id": r.playlistId}, Eq{"id": chunk}}); err != nil { - return err - } - } - - return r.playlistRepo.renumber(ctx, r.playlistId) -} - -func (r *playlistTrackRepository) DeleteAll(ctx context.Context) error { - err := r.delete(ctx, Eq{"playlist_id": r.playlistId}) +func (r *playlistTrackRepository) Delete(ids ...string) error { + err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}}) if err != nil { return err } - return r.playlistRepo.renumber(ctx, r.playlistId) + return r.playlistRepo.renumber(r.playlistId) } -// Reorder moves a track from pos to newPos, shifting other tracks accordingly. newPos is clamped -// to the playlist; a pos outside it is ErrNotFound, since shifting around it would leave a gap. -func (r *playlistTrackRepository) Reorder(ctx context.Context, pos int, newPos int) error { - var res struct{ Max sql.NullInt32 } - if err := r.queryOne(ctx, r.newSelect(ctx).Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}), &res); err != nil { +func (r *playlistTrackRepository) DeleteAll() error { + err := r.delete(Eq{"playlist_id": r.playlistId}) + if err != nil { return err } - last := int(res.Max.Int32) - if pos < 1 || pos > last { - return model.ErrNotFound - } - newPos = min(max(newPos, 1), last) + + return r.playlistRepo.renumber(r.playlistId) +} + +// Reorder moves a track from pos to newPos, shifting other tracks accordingly. +func (r *playlistTrackRepository) Reorder(pos int, newPos int) error { if pos == newPos { return nil } pid := r.playlistId // Step 1: Move the source track out of the way (temporary sentinel value) - _, err := r.executeSQL(ctx, Expr( + _, err := r.executeSQL(Expr( `UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos)) if err != nil { return err @@ -298,11 +222,11 @@ func (r *playlistTrackRepository) Reorder(ctx context.Context, pos int, newPos i // Step 2: Shift the affected range using negative values to avoid unique constraint violations if pos < newPos { - _, err = r.executeSQL(ctx, Expr( + _, err = r.executeSQL(Expr( `UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`, pid, pos, newPos)) } else { - _, err = r.executeSQL(ctx, Expr( + _, err = r.executeSQL(Expr( `UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`, pid, newPos, pos)) } @@ -311,14 +235,14 @@ func (r *playlistTrackRepository) Reorder(ctx context.Context, pos int, newPos i } // Step 3: Flip the shifted range back to positive - _, err = r.executeSQL(ctx, Expr( + _, err = r.executeSQL(Expr( `UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0 AND id != -999999`, pid)) if err != nil { return err } // Step 4: Place the source track at its new position - _, err = r.executeSQL(ctx, Expr( + _, err = r.executeSQL(Expr( `UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid)) return err } diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go deleted file mode 100644 index 119c57116..000000000 --- a/persistence/playlist_track_repository_test.go +++ /dev/null @@ -1,407 +0,0 @@ -package persistence - -import ( - "context" - "strconv" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// sqliteMaxVariables is SQLITE_MAX_VARIABLE_NUMBER as compiled into the driver -const sqliteMaxVariables = 32766 - -var _ = Describe("PlaylistTrackRepository", func() { - var repo model.PlaylistTrackRepository - var ctx context.Context - - BeforeEach(func() { - ctx = log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(GetDBXBuilder()).Tracks(ctx, plsBest.ID, true) - }) - - Describe("GetCursor", func() { - It("yields the same tracks as GetAll", func() { - opts := model.QueryOptions{Sort: "id"} - want, err := repo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).To(HaveLen(2)) - - Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.PlaylistTrack(want))) - }) - - It("honors Max and Offset", func() { - opts := model.QueryOptions{Sort: "id", Max: 1, Offset: 1} - want, err := repo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).To(HaveLen(1)) - - Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.PlaylistTrack(want))) - }) - }) - - Describe("GetAll", func() { - It("returns every row under a random sort, despite the integer id", func() { - // playlist_tracks.id is an INTEGER, so SEEDEDRAND drops every row unless it is cast to - // TEXT, and it fails silently: no error, just no rows. - all, err := repo.GetAll(ctx, model.QueryOptions{Sort: "random"}) - Expect(err).ToNot(HaveOccurred()) - Expect(all).To(HaveLen(2), "a random sort must not silently drop rows") - - got, err := repo.GetAll(ctx, model.QueryOptions{Sort: "random", Max: 1}) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(1)) - }) - }) - - Describe("CountAll", func() { - It("returns the number of tracks in the playlist", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) - }) - - It("ignores Max and Offset", func() { - Expect(repo.CountAll(ctx, model.QueryOptions{Max: 1, Offset: 1})).To(Equal(int64(2))) - }) - }) - - Describe("GetMediaFileIDs", func() { - It("returns the song ids in playlist order", func() { - Expect(repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id"})). - To(Equal([]string{songDayInALife.ID, songRadioactivity.ID})) - }) - - It("honors Max and Offset", func() { - Expect(repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id", Max: 1, Offset: 1})). - To(Equal([]string{songRadioactivity.ID})) - }) - }) - - Describe("Insert", func() { - var tracks model.PlaylistTrackRepository - - BeforeEach(func() { - plsRepo := NewPlaylistRepository(GetDBXBuilder()) - pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"} - Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) - - tracks = plsRepo.Tracks(ctx, pls.ID, false) - Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2)) - }) - - order := func() []string { - ids, err := tracks.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id"}) - Expect(err).ToNot(HaveOccurred()) - return ids - } - - DescribeTable("inserts before a 1-based position, keeping the new tracks' order", - func(pos int, want func() []string) { - Expect(tracks.Insert(ctx, []string{songComeTogether.ID, songAntenna.ID}, pos)).To(Equal(2)) - Expect(order()).To(Equal(want())) - Expect(tracks.CountAll(ctx)).To(Equal(int64(4))) - }, - Entry("in the middle", 2, func() []string { - return []string{songDayInALife.ID, songComeTogether.ID, songAntenna.ID, songRadioactivity.ID} - }), - Entry("at the start, for zero or less", 0, func() []string { - return []string{songComeTogether.ID, songAntenna.ID, songDayInALife.ID, songRadioactivity.ID} - }), - Entry("at the end, past the last position", 9, func() []string { - return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID, songAntenna.ID} - }), - ) - - It("renumbers positions contiguously", func() { - Expect(tracks.Insert(ctx, []string{songComeTogether.ID}, 1)).To(Equal(1)) - all, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"}) - Expect(err).ToNot(HaveOccurred()) - Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"})) - }) - }) - - Describe("Reorder", func() { - var tracks model.PlaylistTrackRepository - - BeforeEach(func() { - plsRepo := NewPlaylistRepository(GetDBXBuilder()) - pls := model.Playlist{Name: "Reorder", OwnerID: "userid", OwnerName: "userid"} - Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) - - tracks = plsRepo.Tracks(ctx, pls.ID, false) - Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})).To(Equal(3)) - }) - - rows := func() ([]string, []string) { - all, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"}) - Expect(err).ToNot(HaveOccurred()) - var ids, songs []string - for _, t := range all { - ids = append(ids, t.ID) - songs = append(songs, t.MediaFileID) - } - return ids, songs - } - - DescribeTable("clamps the destination to the playlist", - func(newPos int, want func() []string) { - Expect(tracks.Reorder(ctx, 1, newPos)).To(Succeed()) - ids, songs := rows() - Expect(ids).To(Equal([]string{"1", "2", "3"})) - Expect(songs).To(Equal(want())) - }, - Entry("past the end moves to the end", 9, func() []string { - return []string{songRadioactivity.ID, songComeTogether.ID, songDayInALife.ID} - }), - Entry("below 1 stays first", -4, func() []string { - return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID} - }), - ) - - DescribeTable("rejects a source position outside the playlist, leaving rows untouched", - func(pos int) { - Expect(tracks.Reorder(ctx, pos, 1)).To(MatchError(model.ErrNotFound)) - ids, songs := rows() - Expect(ids).To(Equal([]string{"1", "2", "3"})) - Expect(songs).To(Equal([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})) - }, - Entry("past the end", 4), - Entry("zero", 0), - ) - }) - - Describe("Delete", func() { - var tracks model.PlaylistTrackRepository - const numTracks = deleteChunkSize*2 + 1 - - positionsUpTo := func(n int) []string { - positions := make([]string, 0, n) - for i := 1; i <= n; i++ { - positions = append(positions, strconv.Itoa(i)) - } - return positions - } - - BeforeEach(func() { - plsRepo := NewPlaylistRepository(GetDBXBuilder()) - - pls := model.Playlist{Name: "Chunked Delete", OwnerID: "userid", OwnerName: "userid"} - Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) - - tracks = plsRepo.Tracks(ctx, pls.ID, false) - songIds := make([]string, numTracks) - for i := range songIds { - songIds[i] = songDayInALife.ID - } - Expect(tracks.Add(ctx, songIds)).To(Equal(numTracks)) - }) - - It("removes positions spanning several chunks, and renumbers what is left", func() { - Expect(tracks.Delete(ctx, positionsUpTo(numTracks-1)...)).To(Succeed()) - - Expect(tracks.CountAll(ctx)).To(Equal(int64(1))) - remaining, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"}) - Expect(err).ToNot(HaveOccurred()) - Expect(remaining[0].ID).To(Equal("1"), "the surviving track must be renumbered to position 1") - }) - - It("accepts more ids than SQLite allows as bind variables", func() { - Expect(tracks.Delete(ctx, positionsUpTo(sqliteMaxVariables+100)...)).To(Succeed()) - - Expect(tracks.CountAll(ctx)).To(BeZero()) - }) - }) - - Describe("AddArtists", func() { - var tracks model.PlaylistTrackRepository - var joint model.MediaFile - - BeforeEach(func() { - mfRepo := NewMediaFileRepository(GetDBXBuilder()) - joint = mf(model.MediaFile{ID: "pls-coartist-track", Title: "Joint Track", ArtistID: artistPunctuation.ID, - Artist: artistPunctuation.Name, AlbumID: "pls-coartist-album", Album: "Joint Album", - AlbumArtistID: artistKraftwerk.ID, AlbumArtist: artistKraftwerk.Name, Path: p("joint/track.mp3")}) - joint.Participants[model.RoleAlbumArtist] = model.ParticipantList{ - {Artist: artistKraftwerk}, - {Artist: artistBeatles}, - } - Expect(mfRepo.Put(ctx, &joint)).To(Succeed()) - DeferCleanup(func() { _ = mfRepo.Delete(ctx, joint.ID) }) - - plsRepo := NewPlaylistRepository(GetDBXBuilder()) - pls := model.Playlist{Name: "Co-album-artist", OwnerID: adminUser.ID, OwnerName: adminUser.UserName} - Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { _ = plsRepo.Delete(ctx, pls.ID) }) - tracks = plsRepo.Tracks(ctx, pls.ID, false) - }) - - It("adds tracks where the artist is the first album artist", func() { - Expect(tracks.AddArtists(ctx, []string{artistKraftwerk.ID})).To(Equal(1)) - Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID)) - }) - - It("adds tracks where the artist is not the first album artist", func() { - Expect(tracks.AddArtists(ctx, []string{artistBeatles.ID})).To(Equal(1)) - Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID)) - }) - - It("does not add tracks where the artist is only the track artist", func() { - Expect(tracks.AddArtists(ctx, []string{artistPunctuation.ID})).To(Equal(0)) - Expect(tracks.GetMediaFileIDs(ctx)).To(BeEmpty()) - }) - }) - - Describe("library access", func() { - var otherLib model.Library - var restrictedUser model.User - var adminCtx, userCtx context.Context - var userTracks model.PlaylistTrackRepository - var plsID string - - BeforeEach(func() { - adminCtx, otherLib, restrictedUser = restrictedFixture("pls") - userCtx = request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser) - db := GetDBXBuilder() - - adminMr := NewMediaFileRepository(db) - Expect(adminMr.Put(adminCtx, &model.MediaFile{ - ID: "pls-otherlib-track", LibraryID: otherLib.ID, AlbumID: "pls-hidden-album", - Path: "hidden/in-playlist.mp3", Title: "Hidden In Playlist", - })).To(Succeed()) - DeferCleanup(func() { _ = adminMr.Delete(adminCtx, "pls-otherlib-track") }) - - adminPls := NewPlaylistRepository(db) - pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true} - Expect(adminPls.Put(adminCtx, &pls)).To(Succeed()) - plsID = pls.ID - DeferCleanup(func() { _ = adminPls.Delete(adminCtx, plsID) }) - Expect(adminPls.Tracks(adminCtx, plsID, false).Add(adminCtx, []string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2)) - - userTracks = NewPlaylistRepository(db).Tracks(userCtx, plsID, false) - }) - - It("Read does not return a track outside the user's libraries", func() { - _, err := userTracks.Read(userCtx, "2") - Expect(err).To(MatchError(model.ErrNotFound), "position 2 holds a track the user cannot access") - }) - - It("Read still returns a track inside the user's libraries", func() { - trk, err := userTracks.Read(userCtx, "1") - Expect(err).ToNot(HaveOccurred()) - Expect(trk.MediaFile.ID).To(Equal(songDayInALife.ID)) - }) - - It("Count excludes tracks outside the user's libraries", func() { - Expect(userTracks.Count(userCtx)).To(Equal(int64(1)), "Count must agree with the filtered listing") - }) - - It("GetAlbumIDs excludes albums outside the user's libraries", func() { - Expect(userTracks.GetAlbumIDs(userCtx)).ToNot(ContainElement("pls-hidden-album")) - }) - - Describe("Add", func() { - var ownTracks model.PlaylistTrackRepository - - BeforeEach(func() { - userPls := NewPlaylistRepository(GetDBXBuilder()) - own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName} - Expect(userPls.Put(userCtx, &own)).To(Succeed()) - DeferCleanup(func() { _ = NewPlaylistRepository(GetDBXBuilder()).Delete(adminCtx, own.ID) }) - ownTracks = userPls.Tracks(userCtx, own.ID, false) - }) - - It("drops ids outside the user's libraries", func() { - Expect(ownTracks.Add(userCtx, []string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1)) - Expect(ownTracks.GetMediaFileIDs(userCtx)).To(ConsistOf(songDayInALife.ID)) - }) - - It("drops them when reached through AddAlbums", func() { - Expect(ownTracks.AddAlbums(userCtx, []string{"pls-hidden-album"})).To(BeZero()) - }) - - It("drops them when reached through Insert", func() { - Expect(ownTracks.Add(userCtx, []string{songDayInALife.ID})).To(Equal(1)) - - Expect(ownTracks.Insert(userCtx, []string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1)) - - Expect(ownTracks.GetMediaFileIDs(userCtx)).To(Equal([]string{songComeTogether.ID, songDayInALife.ID})) - trks, err := ownTracks.GetAll(userCtx, model.QueryOptions{Sort: "id"}) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(trks, func(t model.PlaylistTrack) string { return t.ID })).To(Equal([]string{"1", "2"}), - "positions must stay contiguous when an id is dropped") - }) - }) - - Describe("Put", func() { - storedIDs := func(id string) []string { - ids, err := NewPlaylistRepository(GetDBXBuilder()).Tracks(adminCtx, id, false).GetMediaFileIDs(adminCtx) - Expect(err).ToNot(HaveOccurred()) - return ids - } - put := func(ctx context.Context, owner model.User, pls *model.Playlist, ids ...string) string { - pls.OwnerID = owner.ID - pls.Tracks = nil - pls.AddMediaFilesByID(ids) - Expect(NewPlaylistRepository(GetDBXBuilder()).Put(ctx, pls)).To(Succeed()) - DeferCleanup(func() { _ = NewPlaylistRepository(GetDBXBuilder()).Delete(adminCtx, pls.ID) }) - return pls.ID - } - - It("drops ids outside the user's libraries when creating a playlist", func() { - id := put(userCtx, restrictedUser, &model.Playlist{Name: "Created"}, songDayInALife.ID, "pls-otherlib-track") - - Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID})) - }) - - It("drops them when replacing the tracks of an existing playlist", func() { - pls := &model.Playlist{Name: "Replaced"} - put(userCtx, restrictedUser, pls, songDayInALife.ID) - - put(userCtx, restrictedUser, pls, "pls-otherlib-track") - - Expect(storedIDs(pls.ID)).To(BeEmpty()) - }) - - It("does not count a dropped id, so it cannot be told apart from an unknown one", func() { - hidden := put(userCtx, restrictedUser, &model.Playlist{Name: "Hidden"}, songDayInALife.ID, "pls-otherlib-track") - unknown := put(userCtx, restrictedUser, &model.Playlist{Name: "Unknown"}, songDayInALife.ID, "no-such-track") - - userPls := NewPlaylistRepository(GetDBXBuilder()) - h, err := userPls.Get(userCtx, hidden) - Expect(err).ToNot(HaveOccurred()) - u, err := userPls.Get(userCtx, unknown) - Expect(err).ToNot(HaveOccurred()) - Expect(h.SongCount).To(Equal(u.SongCount)) - Expect(h.Duration).To(Equal(u.Duration)) - Expect(h.Size).To(Equal(u.Size)) - }) - - It("keeps order and duplicates of the accessible ids", func() { - id := put(userCtx, restrictedUser, &model.Playlist{Name: "Ordered"}, - songDayInALife.ID, "pls-otherlib-track", songComeTogether.ID, songDayInALife.ID) - - Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, songComeTogether.ID, songDayInALife.ID})) - }) - - It("keeps every id when run as an admin, as the scanner's playlist sync does", func() { - id := put(adminCtx, adminUser, &model.Playlist{Name: "Synced"}, songDayInALife.ID, "pls-otherlib-track") - - Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, "pls-otherlib-track"})) - }) - }) - - It("still shows everything to an admin", func() { - adminTracks := NewPlaylistRepository(GetDBXBuilder()).Tracks(adminCtx, plsID, false) - Expect(adminTracks.Count(adminCtx)).To(Equal(int64(2))) - _, err := adminTracks.Read(adminCtx, "2") - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go index 9dc520c44..c952b42b1 100644 --- a/persistence/playqueue_repository.go +++ b/persistence/playqueue_repository.go @@ -17,8 +17,9 @@ type playQueueRepository struct { sqlRepository } -func NewPlayQueueRepository(db dbx.Builder) model.PlayQueueRepository { +func NewPlayQueueRepository(ctx context.Context, db dbx.Builder) model.PlayQueueRepository { r := &playQueueRepository{} + r.ctx = ctx r.db = db r.tableName = "playqueue" return r @@ -35,13 +36,13 @@ type playQueue struct { UpdatedAt time.Time `structs:"updated_at"` } -func (r *playQueueRepository) Store(ctx context.Context, q *model.PlayQueue, colNames ...string) error { - u := loggedUser(ctx) +func (r *playQueueRepository) Store(q *model.PlayQueue, colNames ...string) error { + u := loggedUser(r.ctx) // Always find existing playqueue for this user - existingQueue, err := r.Retrieve(ctx, q.UserID) + existingQueue, err := r.Retrieve(q.UserID) if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Error retrieving existing playqueue", "user", u.UserName, err) + log.Error(r.ctx, "Error retrieving existing playqueue", "user", u.UserName, err) return err } @@ -52,9 +53,9 @@ func (r *playQueueRepository) Store(ctx context.Context, q *model.PlayQueue, col // When no specific columns are provided, we replace the whole queue if len(colNames) == 0 { - err := r.clearPlayQueue(ctx, q.UserID) + err := r.clearPlayQueue(q.UserID) if err != nil { - log.Error(ctx, "Error deleting previous playqueue", "user", u.UserName, err) + log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err) return err } if len(q.Items) == 0 { @@ -67,28 +68,29 @@ func (r *playQueueRepository) Store(ctx context.Context, q *model.PlayQueue, col pq.CreatedAt = time.Now() } pq.UpdatedAt = time.Now() - _, err = r.put(ctx, pq.ID, pq, colNames...) + _, err = r.put(pq.ID, pq, colNames...) if err != nil { - log.Error(ctx, "Error saving playqueue", "user", u.UserName, err) + log.Error(r.ctx, "Error saving playqueue", "user", u.UserName, err) return err } return nil } -func (r *playQueueRepository) RetrieveWithMediaFiles(ctx context.Context, userId string) (*model.PlayQueue, error) { - sel := r.newSelect(ctx).Columns("*").Where(Eq{"user_id": userId}) +func (r *playQueueRepository) RetrieveWithMediaFiles(userId string) (*model.PlayQueue, error) { + sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) var res playQueue - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) q := r.toModel(&res) - q.Items = r.loadTracks(ctx, q.Items) + q.Items = r.loadTracks(q.Items) return &q, err } -func (r *playQueueRepository) Retrieve(ctx context.Context, userId string) (*model.PlayQueue, error) { - sel := r.newSelect(ctx).Columns("*").Where(Eq{"user_id": userId}) +func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) { + sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) var res playQueue - err := r.queryOne(ctx, sel, &res) - return new(r.toModel(&res)), err + err := r.queryOne(sel, &res) + q := r.toModel(&res) + return &q, err } func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue { @@ -130,12 +132,12 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue { // loadTracks loads the tracks from the database. It receives a list of track IDs and returns a list of MediaFiles // in the same order as the input list. -func (r *playQueueRepository) loadTracks(ctx context.Context, tracks model.MediaFiles) model.MediaFiles { +func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFiles { if len(tracks) == 0 { return nil } - mfRepo := NewMediaFileRepository(r.db) + mfRepo := NewMediaFileRepository(r.ctx, r.db) trackMap := map[string]model.MediaFile{} // Create an iterator to collect all track IDs @@ -144,10 +146,10 @@ func (r *playQueueRepository) loadTracks(ctx context.Context, tracks model.Media // Break the list in chunks, up to 500 items, to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit for chunk := range slice.CollectChunks(ids, 500) { idsFilter := Eq{"media_file.id": chunk} - tracks, err := mfRepo.GetAll(ctx, model.QueryOptions{Filters: idsFilter}) + tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter}) if err != nil { - u := loggedUser(ctx) - log.Error(ctx, "Could not load playqueue/bookmark's tracks", "user", u.UserName, err) + u := loggedUser(r.ctx) + log.Error(r.ctx, "Could not load playqueue/bookmark's tracks", "user", u.UserName, err) } for _, t := range tracks { trackMap[t.ID] = t @@ -165,12 +167,12 @@ func (r *playQueueRepository) loadTracks(ctx context.Context, tracks model.Media return newTracks } -func (r *playQueueRepository) clearPlayQueue(ctx context.Context, userId string) error { - return r.delete(ctx, Eq{"user_id": userId}) +func (r *playQueueRepository) clearPlayQueue(userId string) error { + return r.delete(Eq{"user_id": userId}) } -func (r *playQueueRepository) Clear(ctx context.Context, userId string) error { - return r.clearPlayQueue(ctx, userId) +func (r *playQueueRepository) Clear(userId string) error { + return r.clearPlayQueue(userId) } var _ model.PlayQueueRepository = (*playQueueRepository)(nil) diff --git a/persistence/playqueue_repository_test.go b/persistence/playqueue_repository_test.go index 877faddfc..2bcc88fd0 100644 --- a/persistence/playqueue_repository_test.go +++ b/persistence/playqueue_repository_test.go @@ -22,15 +22,15 @@ var _ = Describe("PlayQueueRepository", func() { DeferCleanup(configtest.SetupConfig()) ctx = log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlayQueueRepository(GetDBXBuilder()) + repo = NewPlayQueueRepository(ctx, GetDBXBuilder()) }) Describe("Store", func() { It("stores a complete playqueue", func() { expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, expected)).To(Succeed()) + Expect(repo.Store(expected)).To(Succeed()) - actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") + actual, err := repo.RetrieveWithMediaFiles("userid") Expect(err).ToNot(HaveOccurred()) AssertPlayQueue(expected, actual) Expect(countPlayQueues(repo, "userid")).To(Equal(1)) @@ -39,13 +39,13 @@ var _ = Describe("PlayQueueRepository", func() { It("replaces existing playqueue when storing without column names", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(ctx, initial)).To(Succeed()) + Expect(repo.Store(initial)).To(Succeed()) By("Storing replacement playqueue") replacement := aPlayQueue("userid", 1, 200, songDayInALife, songAntenna) - Expect(repo.Store(ctx, replacement)).To(Succeed()) + Expect(repo.Store(replacement)).To(Succeed()) - actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") + actual, err := repo.RetrieveWithMediaFiles("userid") Expect(err).ToNot(HaveOccurred()) AssertPlayQueue(replacement, actual) Expect(countPlayQueues(repo, "userid")).To(Equal(1)) @@ -54,24 +54,24 @@ var _ = Describe("PlayQueueRepository", func() { It("clears playqueue when storing empty items", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(ctx, initial)).To(Succeed()) + Expect(repo.Store(initial)).To(Succeed()) By("Storing empty playqueue") empty := aPlayQueue("userid", 0, 0) - Expect(repo.Store(ctx, empty)).To(Succeed()) + Expect(repo.Store(empty)).To(Succeed()) By("Verifying playqueue is cleared") - _, err := repo.Retrieve(ctx, "userid") + _, err := repo.Retrieve("userid") Expect(err).To(MatchError(model.ErrNotFound)) }) It("updates only current field when specified", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, initial)).To(Succeed()) + Expect(repo.Store(initial)).To(Succeed()) By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve(ctx, "userid") + existing, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) By("Updating only current field") @@ -81,10 +81,10 @@ var _ = Describe("PlayQueueRepository", func() { Current: 1, ChangedBy: "test-update", } - Expect(repo.Store(ctx, update, "current")).To(Succeed()) + Expect(repo.Store(update, "current")).To(Succeed()) By("Verifying only current was updated") - actual, err := repo.Retrieve(ctx, "userid") + actual, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Current).To(Equal(1)) Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged @@ -94,10 +94,10 @@ var _ = Describe("PlayQueueRepository", func() { It("updates only position field when specified", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 1, 100, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, initial)).To(Succeed()) + Expect(repo.Store(initial)).To(Succeed()) By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve(ctx, "userid") + existing, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) By("Updating only position field") @@ -107,10 +107,10 @@ var _ = Describe("PlayQueueRepository", func() { Position: 500, ChangedBy: "test-update", } - Expect(repo.Store(ctx, update, "position")).To(Succeed()) + Expect(repo.Store(update, "position")).To(Succeed()) By("Verifying only position was updated") - actual, err := repo.Retrieve(ctx, "userid") + actual, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Position).To(Equal(int64(500))) Expect(actual.Current).To(Equal(1)) // Should remain unchanged @@ -120,10 +120,10 @@ var _ = Describe("PlayQueueRepository", func() { It("updates multiple specified fields", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(ctx, initial)).To(Succeed()) + Expect(repo.Store(initial)).To(Succeed()) By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve(ctx, "userid") + existing, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) By("Updating current and position fields") @@ -134,10 +134,10 @@ var _ = Describe("PlayQueueRepository", func() { Position: 300, ChangedBy: "test-update", } - Expect(repo.Store(ctx, update, "current", "position")).To(Succeed()) + Expect(repo.Store(update, "current", "position")).To(Succeed()) By("Verifying both fields were updated") - actual, err := repo.Retrieve(ctx, "userid") + actual, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Current).To(Equal(1)) Expect(actual.Position).To(Equal(int64(300))) @@ -147,10 +147,10 @@ var _ = Describe("PlayQueueRepository", func() { It("preserves existing data when updating with empty items list and column names", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, initial)).To(Succeed()) + Expect(repo.Store(initial)).To(Succeed()) By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve(ctx, "userid") + existing, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) By("Updating only position with empty items") @@ -161,10 +161,10 @@ var _ = Describe("PlayQueueRepository", func() { ChangedBy: "test-update", Items: []model.MediaFile{}, // Empty items } - Expect(repo.Store(ctx, update, "position")).To(Succeed()) + Expect(repo.Store(update, "position")).To(Succeed()) By("Verifying items are preserved") - actual, err := repo.Retrieve(ctx, "userid") + actual, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Position).To(Equal(int64(200))) Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged @@ -173,21 +173,21 @@ var _ = Describe("PlayQueueRepository", func() { It("ensures only one record per user by reusing existing record ID", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(ctx, initial)).To(Succeed()) + Expect(repo.Store(initial)).To(Succeed()) initialCount := countPlayQueues(repo, "userid") Expect(initialCount).To(Equal(1)) By("Storing another playqueue with different ID but same user") different := aPlayQueue("userid", 1, 200, songDayInALife) different.ID = "different-id" // Force a different ID - Expect(repo.Store(ctx, different)).To(Succeed()) + Expect(repo.Store(different)).To(Succeed()) By("Verifying only one record exists for the user") finalCount := countPlayQueues(repo, "userid") Expect(finalCount).To(Equal(1)) By("Verifying the record was updated, not duplicated") - actual, err := repo.Retrieve(ctx, "userid") + actual, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Current).To(Equal(1)) // Should be updated value Expect(actual.Position).To(Equal(int64(200))) // Should be updated value @@ -198,7 +198,7 @@ var _ = Describe("PlayQueueRepository", func() { It("ensures only one record per user even with partial updates", func() { By("Storing initial playqueue") initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, initial)).To(Succeed()) + Expect(repo.Store(initial)).To(Succeed()) initialCount := countPlayQueues(repo, "userid") Expect(initialCount).To(Equal(1)) @@ -209,14 +209,14 @@ var _ = Describe("PlayQueueRepository", func() { Current: 1, ChangedBy: "test-partial", } - Expect(repo.Store(ctx, partialUpdate, "current")).To(Succeed()) + Expect(repo.Store(partialUpdate, "current")).To(Succeed()) By("Verifying only one record still exists for the user") finalCount := countPlayQueues(repo, "userid") Expect(finalCount).To(Equal(1)) By("Verifying the existing record was updated with new current value") - actual, err := repo.Retrieve(ctx, "userid") + actual, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) Expect(actual.Current).To(Equal(1)) // Should be updated value Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged @@ -226,7 +226,7 @@ var _ = Describe("PlayQueueRepository", func() { Describe("Retrieve", func() { It("returns notfound error if there's no playqueue for the user", func() { - _, err := repo.Retrieve(ctx, "user999") + _, err := repo.Retrieve("user999") Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -234,9 +234,9 @@ var _ = Describe("PlayQueueRepository", func() { By("Storing a playqueue for the user") expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, expected)).To(Succeed()) + Expect(repo.Store(expected)).To(Succeed()) - actual, err := repo.Retrieve(ctx, "userid") + actual, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) // Basic playqueue properties should match @@ -263,19 +263,19 @@ var _ = Describe("PlayQueueRepository", func() { newSong := songRadioactivity newSong.ID = "temp-track" newSong.Path = "/new-path" - mfRepo := NewMediaFileRepository(GetDBXBuilder()) + mfRepo := NewMediaFileRepository(ctx, GetDBXBuilder()) - Expect(mfRepo.Put(ctx, &newSong)).To(Succeed()) + Expect(mfRepo.Put(&newSong)).To(Succeed()) // Create a playqueue with the new song pq := aPlayQueue("userid", 0, 0, newSong, songAntenna) - Expect(repo.Store(ctx, pq)).To(Succeed()) + Expect(repo.Store(pq)).To(Succeed()) // Delete the new song from the database - Expect(mfRepo.Delete(ctx, "temp-track")).To(Succeed()) + Expect(mfRepo.Delete("temp-track")).To(Succeed()) // Retrieve the playqueue with Retrieve method - actual, err := repo.Retrieve(ctx, "userid") + actual, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) // The playqueue should still contain both track IDs (including the deleted one) @@ -295,7 +295,7 @@ var _ = Describe("PlayQueueRepository", func() { Describe("RetrieveWithMediaFiles", func() { It("returns notfound error if there's no playqueue for the user", func() { - _, err := repo.RetrieveWithMediaFiles(ctx, "user999") + _, err := repo.RetrieveWithMediaFiles("user999") Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -303,9 +303,9 @@ var _ = Describe("PlayQueueRepository", func() { By("Storing a playqueue for the user") expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, expected)).To(Succeed()) + Expect(repo.Store(expected)).To(Succeed()) - actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") + actual, err := repo.RetrieveWithMediaFiles("userid") Expect(err).ToNot(HaveOccurred()) AssertPlayQueue(expected, actual) @@ -316,26 +316,26 @@ var _ = Describe("PlayQueueRepository", func() { newSong := songRadioactivity newSong.ID = "temp-track" newSong.Path = "/new-path" - mfRepo := NewMediaFileRepository(GetDBXBuilder()) + mfRepo := NewMediaFileRepository(ctx, GetDBXBuilder()) - Expect(mfRepo.Put(ctx, &newSong)).To(Succeed()) + Expect(mfRepo.Put(&newSong)).To(Succeed()) // Create a playqueue with the new song pq := aPlayQueue("userid", 0, 0, newSong, songAntenna) - Expect(repo.Store(ctx, pq)).To(Succeed()) + Expect(repo.Store(pq)).To(Succeed()) // Retrieve the playqueue - actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") + actual, err := repo.RetrieveWithMediaFiles("userid") Expect(err).ToNot(HaveOccurred()) // The playqueue should contain both tracks AssertPlayQueue(pq, actual) // Delete the new song - Expect(mfRepo.Delete(ctx, "temp-track")).To(Succeed()) + Expect(mfRepo.Delete("temp-track")).To(Succeed()) // Retrieve the playqueue - actual, err = repo.RetrieveWithMediaFiles(ctx, "userid") + actual, err = repo.RetrieveWithMediaFiles("userid") Expect(err).ToNot(HaveOccurred()) // The playqueue should not contain the deleted track @@ -348,48 +348,48 @@ var _ = Describe("PlayQueueRepository", func() { It("clears an existing playqueue", func() { By("Storing a playqueue") expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, expected)).To(Succeed()) + Expect(repo.Store(expected)).To(Succeed()) By("Verifying playqueue exists") - _, err := repo.Retrieve(ctx, "userid") + _, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) By("Clearing the playqueue") - Expect(repo.Clear(ctx, "userid")).To(Succeed()) + Expect(repo.Clear("userid")).To(Succeed()) By("Verifying playqueue is cleared") - _, err = repo.Retrieve(ctx, "userid") + _, err = repo.Retrieve("userid") Expect(err).To(MatchError(model.ErrNotFound)) }) It("does not error when clearing non-existent playqueue", func() { // Clear should not error even if no playqueue exists - Expect(repo.Clear(ctx, "nonexistent-user")).To(Succeed()) + Expect(repo.Clear("nonexistent-user")).To(Succeed()) }) It("only clears the specified user's playqueue", func() { By("Creating users in the database to avoid foreign key constraints") - userRepo := NewUserRepository(GetDBXBuilder()) + userRepo := NewUserRepository(ctx, GetDBXBuilder()) user1 := &model.User{ID: "user1", UserName: "user1", Name: "User 1", Email: "user1@test.com"} user2 := &model.User{ID: "user2", UserName: "user2", Name: "User 2", Email: "user2@test.com"} - Expect(userRepo.Put(ctx, user1)).To(Succeed()) - Expect(userRepo.Put(ctx, user2)).To(Succeed()) + Expect(userRepo.Put(user1)).To(Succeed()) + Expect(userRepo.Put(user2)).To(Succeed()) By("Storing playqueues for two users") user1Queue := aPlayQueue("user1", 0, 100, songComeTogether) user2Queue := aPlayQueue("user2", 1, 200, songDayInALife) - Expect(repo.Store(ctx, user1Queue)).To(Succeed()) - Expect(repo.Store(ctx, user2Queue)).To(Succeed()) + Expect(repo.Store(user1Queue)).To(Succeed()) + Expect(repo.Store(user2Queue)).To(Succeed()) By("Clearing only user1's playqueue") - Expect(repo.Clear(ctx, "user1")).To(Succeed()) + Expect(repo.Clear("user1")).To(Succeed()) By("Verifying user1's playqueue is cleared") - _, err := repo.Retrieve(ctx, "user1") + _, err := repo.Retrieve("user1") Expect(err).To(MatchError(model.ErrNotFound)) By("Verifying user2's playqueue still exists") - actual, err := repo.Retrieve(ctx, "user2") + actual, err := repo.Retrieve("user2") Expect(err).ToNot(HaveOccurred()) Expect(actual.UserID).To(Equal("user2")) Expect(actual.Current).To(Equal(1)) @@ -400,7 +400,7 @@ var _ = Describe("PlayQueueRepository", func() { func countPlayQueues(repo model.PlayQueueRepository, userId string) int { r := repo.(*playQueueRepository) - c, err := r.count(GinkgoT().Context(), squirrel.Select().Where(squirrel.Eq{"user_id": userId})) + c, err := r.count(squirrel.Select().Where(squirrel.Eq{"user_id": userId})) if err != nil { panic(err) } diff --git a/persistence/plugin_cleanup_test.go b/persistence/plugin_cleanup_test.go index 08959075d..bfe6d60ca 100644 --- a/persistence/plugin_cleanup_test.go +++ b/persistence/plugin_cleanup_test.go @@ -1,8 +1,6 @@ package persistence import ( - "context" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" @@ -13,47 +11,30 @@ var _ = Describe("Plugin Cleanup", func() { var pluginRepo model.PluginRepository var userRepo model.UserRepository var libraryRepo model.LibraryRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin", UserName: "admin", IsAdmin: true}) + ctx := GinkgoT().Context() + ctx = request.WithUser(ctx, model.User{ID: "admin", UserName: "admin", IsAdmin: true}) db := GetDBXBuilder() - pluginRepo = NewPluginRepository(db) - userRepo = NewUserRepository(db) - libraryRepo = NewLibraryRepository(db) + pluginRepo = NewPluginRepository(ctx, db) + userRepo = NewUserRepository(ctx, db) + libraryRepo = NewLibraryRepository(ctx, db) // Clean up any existing plugins - all, _ := pluginRepo.GetAll(ctx) + all, _ := pluginRepo.GetAll() for _, p := range all { - _ = pluginRepo.Delete(ctx, p.ID) + _ = pluginRepo.Delete(p.ID) } }) AfterEach(func() { // Clean up after tests - all, _ := pluginRepo.GetAll(ctx) + all, _ := pluginRepo.GetAll() for _, p := range all { - _ = pluginRepo.Delete(ctx, p.ID) + _ = pluginRepo.Delete(p.ID) } }) - Describe("UserRepository.Delete", func() { - It("cleans up plugin references for users deleted before a later id fails", func() { - Expect(userRepo.Put(ctx, &model.User{ID: "bulk-1", UserName: "bulk-1", NewPassword: "x"})).To(Succeed()) - DeferCleanup(func() { _ = userRepo.Delete(ctx, "bulk-1") }) - Expect(pluginRepo.Put(ctx, &model.Plugin{ - ID: "bulk-plugin", Path: "/plugins/bulk.wasm", Manifest: `{"name":"bulk"}`, SHA256: "def456", - Users: `["bulk-1","other"]`, Enabled: true, - })).To(Succeed()) - - Expect(userRepo.Delete(ctx, "bulk-1", "does-not-exist")).To(MatchError(model.ErrNotFound)) - - updated, err := pluginRepo.Get(ctx, "bulk-plugin") - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Users).To(Equal(`["other"]`)) - }) - }) - Describe("cleanupPluginUserReferences", func() { It("removes user ID from plugin users array", func() { // Create a plugin with multiple users @@ -65,14 +46,14 @@ var _ = Describe("Plugin Cleanup", func() { Users: `["user1","user2","user3"]`, Enabled: true, } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) + Expect(pluginRepo.Put(plugin)).To(Succeed()) // Clean up user2 reference db := GetDBXBuilder() Expect(cleanupPluginUserReferences(db, "user2")).To(Succeed()) // Verify user2 was removed - updated, err := pluginRepo.Get(ctx, "test-plugin") + updated, err := pluginRepo.Get("test-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Users).To(Equal(`["user1","user3"]`)) Expect(updated.Enabled).To(BeTrue()) // Still has users, should remain enabled @@ -89,14 +70,14 @@ var _ = Describe("Plugin Cleanup", func() { AllUsers: false, Enabled: true, } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) + Expect(pluginRepo.Put(plugin)).To(Succeed()) // Remove the only user db := GetDBXBuilder() Expect(cleanupPluginUserReferences(db, "only-user")).To(Succeed()) // Verify plugin was auto-disabled - updated, err := pluginRepo.Get(ctx, "user-plugin") + updated, err := pluginRepo.Get("user-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Users).To(Equal(`[]`)) Expect(updated.Enabled).To(BeFalse()) @@ -112,14 +93,14 @@ var _ = Describe("Plugin Cleanup", func() { AllUsers: true, Enabled: true, } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) + Expect(pluginRepo.Put(plugin)).To(Succeed()) // Remove the user (but allUsers is true) db := GetDBXBuilder() Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed()) // Plugin should still be enabled because allUsers is true - updated, err := pluginRepo.Get(ctx, "all-users-plugin") + updated, err := pluginRepo.Get("all-users-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Enabled).To(BeTrue()) }) @@ -133,14 +114,14 @@ var _ = Describe("Plugin Cleanup", func() { Users: `["user1"]`, Enabled: true, } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) + Expect(pluginRepo.Put(plugin)).To(Succeed()) // Remove the user db := GetDBXBuilder() Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed()) // Plugin should still be enabled (no users permission requirement) - updated, err := pluginRepo.Get(ctx, "no-users-perm") + updated, err := pluginRepo.Get("no-users-perm") Expect(err).ToNot(HaveOccurred()) Expect(updated.Users).To(Equal(`[]`)) Expect(updated.Enabled).To(BeTrue()) @@ -158,14 +139,14 @@ var _ = Describe("Plugin Cleanup", func() { Libraries: `[1,2,3]`, Enabled: true, } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) + Expect(pluginRepo.Put(plugin)).To(Succeed()) // Clean up library 2 reference db := GetDBXBuilder() Expect(cleanupPluginLibraryReferences(db, 2)).To(Succeed()) // Verify library 2 was removed - updated, err := pluginRepo.Get(ctx, "lib-plugin") + updated, err := pluginRepo.Get("lib-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Libraries).To(Equal(`[1,3]`)) }) @@ -181,14 +162,14 @@ var _ = Describe("Plugin Cleanup", func() { AllLibraries: false, Enabled: true, } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) + Expect(pluginRepo.Put(plugin)).To(Succeed()) // Remove the only library db := GetDBXBuilder() Expect(cleanupPluginLibraryReferences(db, 99)).To(Succeed()) // Verify plugin was auto-disabled - updated, err := pluginRepo.Get(ctx, "lib-only-plugin") + updated, err := pluginRepo.Get("lib-only-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Libraries).To(Equal(`[]`)) Expect(updated.Enabled).To(BeFalse()) @@ -204,14 +185,14 @@ var _ = Describe("Plugin Cleanup", func() { AllLibraries: true, Enabled: true, } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) + Expect(pluginRepo.Put(plugin)).To(Succeed()) // Remove the library (but allLibraries is true) db := GetDBXBuilder() Expect(cleanupPluginLibraryReferences(db, 1)).To(Succeed()) // Plugin should still be enabled - updated, err := pluginRepo.Get(ctx, "all-libs-plugin") + updated, err := pluginRepo.Get("all-libs-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Enabled).To(BeTrue()) }) @@ -226,7 +207,7 @@ var _ = Describe("Plugin Cleanup", func() { IsAdmin: false, } user.NewPassword = "password123" - Expect(userRepo.Put(ctx, user)).To(Succeed()) + Expect(userRepo.Put(user)).To(Succeed()) // Create a plugin referencing this user plugin := &model.Plugin{ @@ -237,13 +218,13 @@ var _ = Describe("Plugin Cleanup", func() { Users: `["test-delete-user","other-user"]`, Enabled: true, } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) + Expect(pluginRepo.Put(plugin)).To(Succeed()) // Delete the user - Expect(userRepo.Delete(ctx, "test-delete-user")).To(Succeed()) + Expect(userRepo.Delete("test-delete-user")).To(Succeed()) // Verify user was removed from plugin - updated, err := pluginRepo.Get(ctx, "user-ref-plugin") + updated, err := pluginRepo.Get("user-ref-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Users).To(Equal(`["other-user"]`)) }) @@ -257,7 +238,7 @@ var _ = Describe("Plugin Cleanup", func() { Name: "Test Library", Path: "/tmp/test-lib", } - Expect(libraryRepo.Put(ctx, library)).To(Succeed()) + Expect(libraryRepo.Put(library)).To(Succeed()) // Create a plugin referencing this library plugin := &model.Plugin{ @@ -268,13 +249,13 @@ var _ = Describe("Plugin Cleanup", func() { Libraries: `[99,1]`, Enabled: true, } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) + Expect(pluginRepo.Put(plugin)).To(Succeed()) // Delete the library - Expect(libraryRepo.Delete(ctx, 99)).To(Succeed()) + Expect(libraryRepo.Delete(99)).To(Succeed()) // Verify library was removed from plugin - updated, err := pluginRepo.Get(ctx, "lib-ref-plugin") + updated, err := pluginRepo.Get("lib-ref-plugin") Expect(err).ToNot(HaveOccurred()) Expect(updated.Libraries).To(Equal(`[1]`)) }) diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go index 1545ce2ba..35c32de91 100644 --- a/persistence/plugin_repository.go +++ b/persistence/plugin_repository.go @@ -15,8 +15,9 @@ type pluginRepository struct { sqlRepository } -func NewPluginRepository(db dbx.Builder) model.PluginRepository { +func NewPluginRepository(ctx context.Context, db dbx.Builder) model.PluginRepository { r := &pluginRepository{} + r.ctx = ctx r.db = db r.registerModel(&model.Plugin{}, map[string]filterFunc{ "id": idFilter("plugin"), @@ -25,64 +26,56 @@ func NewPluginRepository(db dbx.Builder) model.PluginRepository { return r } -func (r *pluginRepository) isPermitted(ctx context.Context) bool { - user := loggedUser(ctx) +func (r *pluginRepository) isPermitted() bool { + user := loggedUser(r.ctx) return user.IsAdmin } -func (r *pluginRepository) ClearErrors(ctx context.Context) error { - if !r.isPermitted(ctx) { +func (r *pluginRepository) ClearErrors() error { + if !r.isPermitted() { return rest.ErrPermissionDenied } - // An UPDATE takes the write lock even when nothing matches, so only run it when there is an error to clear - var hasErrors bool - if err := r.db.NewQuery("SELECT EXISTS (SELECT 1 FROM plugin WHERE last_error != '')").Row(&hasErrors); err != nil { - return err - } - if !hasErrors { - return nil - } _, err := r.db.NewQuery("UPDATE plugin SET last_error = '' WHERE last_error != ''").Execute() return err } -func (r *pluginRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - if !r.isPermitted(ctx) { +func (r *pluginRepository) CountAll(options ...model.QueryOptions) (int64, error) { + if !r.isPermitted() { return 0, rest.ErrPermissionDenied } - sql := r.newSelect(ctx) - return r.count(ctx, sql, options...) + sql := r.newSelect() + return r.count(sql, options...) } -func (r *pluginRepository) Delete(ctx context.Context, id string) error { - if !r.isPermitted(ctx) { +func (r *pluginRepository) Delete(id string) error { + if !r.isPermitted() { return rest.ErrPermissionDenied } - return r.delete(ctx, Eq{"id": id}) + return r.delete(Eq{"id": id}) } -func (r *pluginRepository) Get(ctx context.Context, id string) (*model.Plugin, error) { - if !r.isPermitted(ctx) { +func (r *pluginRepository) Get(id string) (*model.Plugin, error) { + if !r.isPermitted() { return nil, rest.ErrPermissionDenied } - sel := r.newSelect(ctx).Where(Eq{"id": id}).Columns("*") + sel := r.newSelect().Where(Eq{"id": id}).Columns("*") res := model.Plugin{} - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *pluginRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Plugins, error) { - if !r.isPermitted(ctx) { +func (r *pluginRepository) GetAll(options ...model.QueryOptions) (model.Plugins, error) { + if !r.isPermitted() { return nil, rest.ErrPermissionDenied } - sel := r.newSelect(ctx, options...).Columns("*") + sel := r.newSelect(options...).Columns("*") res := model.Plugins{} - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) return res, err } -func (r *pluginRepository) Put(ctx context.Context, plugin *model.Plugin) error { - if !r.isPermitted(ctx) { +func (r *pluginRepository) Put(plugin *model.Plugin) error { + if !r.isPermitted() { return rest.ErrPermissionDenied } @@ -128,17 +121,51 @@ func (r *pluginRepository) Put(ctx context.Context, plugin *model.Plugin) error return err } -func (r *pluginRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *pluginRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *pluginRepository) Read(ctx context.Context, id string) (*model.Plugin, error) { - return r.Get(ctx, id) +func (r *pluginRepository) EntityName() string { + return "plugin" } -func (r *pluginRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Plugin, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *pluginRepository) NewInstance() any { + return &model.Plugin{} +} + +func (r *pluginRepository) Read(id string) (any, error) { + return r.Get(id) +} + +func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *pluginRepository) Save(entity any) (string, error) { + p := entity.(*model.Plugin) + if !r.isPermitted() { + return "", rest.ErrPermissionDenied + } + err := r.Put(p) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + return p.ID, err +} + +func (r *pluginRepository) Update(id string, entity any, cols ...string) error { + p := entity.(*model.Plugin) + p.ID = id + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + err := r.Put(p) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err } var _ model.PluginRepository = (*pluginRepository)(nil) -var _ rest.Repository[model.Plugin] = (*pluginRepository)(nil) +var _ rest.Repository = (*pluginRepository)(nil) +var _ rest.Persistable = (*pluginRepository)(nil) diff --git a/persistence/plugin_repository_test.go b/persistence/plugin_repository_test.go index 44330250b..dc68b0892 100644 --- a/persistence/plugin_repository_test.go +++ b/persistence/plugin_repository_test.go @@ -1,10 +1,7 @@ package persistence import ( - "context" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" @@ -13,54 +10,50 @@ import ( var _ = Describe("PluginRepository", func() { var repo model.PluginRepository - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) Describe("Admin User", func() { BeforeEach(func() { + ctx := GinkgoT().Context() ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPluginRepository(GetDBXBuilder()) + repo = NewPluginRepository(ctx, GetDBXBuilder()) // Clean up any existing plugins - all, _ := repo.GetAll(ctx) + all, _ := repo.GetAll() for _, p := range all { - _ = repo.Delete(ctx, p.ID) + _ = repo.Delete(p.ID) } }) AfterEach(func() { // Clean up after tests - all, _ := repo.GetAll(ctx) + all, _ := repo.GetAll() for _, p := range all { - _ = repo.Delete(ctx, p.ID) + _ = repo.Delete(p.ID) } }) Describe("CountAll", func() { It("returns 0 when no plugins exist", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(0))) + Expect(repo.CountAll()).To(Equal(int64(0))) }) It("returns the number of plugins in the DB", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "test-plugin-1", Path: "/plugins/test1.wasm", Manifest: "{}", SHA256: "abc123"}) - _ = repo.Put(ctx, &model.Plugin{ID: "test-plugin-2", Path: "/plugins/test2.wasm", Manifest: "{}", SHA256: "def456"}) + _ = repo.Put(&model.Plugin{ID: "test-plugin-1", Path: "/plugins/test1.wasm", Manifest: "{}", SHA256: "abc123"}) + _ = repo.Put(&model.Plugin{ID: "test-plugin-2", Path: "/plugins/test2.wasm", Manifest: "{}", SHA256: "def456"}) - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) + Expect(repo.CountAll()).To(Equal(int64(2))) }) }) Describe("Delete", func() { It("deletes existing item", func() { plugin := &model.Plugin{ID: "to-delete", Path: "/plugins/delete.wasm", Manifest: "{}", SHA256: "hash"} - _ = repo.Put(ctx, plugin) + _ = repo.Put(plugin) - err := repo.Delete(ctx, plugin.ID) + err := repo.Delete(plugin.ID) Expect(err).To(BeNil()) - _, err = repo.Get(ctx, plugin.ID) + _, err = repo.Get(plugin.ID) Expect(err).To(MatchError(model.ErrNotFound)) }) }) @@ -68,9 +61,9 @@ var _ = Describe("PluginRepository", func() { Describe("Get", func() { It("returns an existing item", func() { plugin := &model.Plugin{ID: "test-get", Path: "/plugins/test.wasm", Manifest: `{"name":"test"}`, SHA256: "hash123"} - _ = repo.Put(ctx, plugin) + _ = repo.Put(plugin) - res, err := repo.Get(ctx, plugin.ID) + res, err := repo.Get(plugin.ID) Expect(err).To(BeNil()) Expect(res.ID).To(Equal(plugin.ID)) Expect(res.Path).To(Equal(plugin.Path)) @@ -78,31 +71,31 @@ var _ = Describe("PluginRepository", func() { }) It("errors when missing", func() { - _, err := repo.Get(ctx, "notanid") + _, err := repo.Get("notanid") Expect(err).To(MatchError(model.ErrNotFound)) }) }) Describe("GetAll", func() { It("returns all items from the DB", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "plugin-a", Path: "/plugins/a.wasm", Manifest: "{}", SHA256: "hash1"}) - _ = repo.Put(ctx, &model.Plugin{ID: "plugin-b", Path: "/plugins/b.wasm", Manifest: "{}", SHA256: "hash2"}) + _ = repo.Put(&model.Plugin{ID: "plugin-a", Path: "/plugins/a.wasm", Manifest: "{}", SHA256: "hash1"}) + _ = repo.Put(&model.Plugin{ID: "plugin-b", Path: "/plugins/b.wasm", Manifest: "{}", SHA256: "hash2"}) - all, err := repo.GetAll(ctx) + all, err := repo.GetAll() Expect(err).To(BeNil()) Expect(all).To(HaveLen(2)) }) It("supports pagination", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "plugin-1", Path: "/plugins/1.wasm", Manifest: "{}", SHA256: "h1"}) - _ = repo.Put(ctx, &model.Plugin{ID: "plugin-2", Path: "/plugins/2.wasm", Manifest: "{}", SHA256: "h2"}) - _ = repo.Put(ctx, &model.Plugin{ID: "plugin-3", Path: "/plugins/3.wasm", Manifest: "{}", SHA256: "h3"}) + _ = repo.Put(&model.Plugin{ID: "plugin-1", Path: "/plugins/1.wasm", Manifest: "{}", SHA256: "h1"}) + _ = repo.Put(&model.Plugin{ID: "plugin-2", Path: "/plugins/2.wasm", Manifest: "{}", SHA256: "h2"}) + _ = repo.Put(&model.Plugin{ID: "plugin-3", Path: "/plugins/3.wasm", Manifest: "{}", SHA256: "h3"}) - page1, err := repo.GetAll(ctx, model.QueryOptions{Max: 2, Offset: 0, Sort: "id"}) + page1, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 0, Sort: "id"}) Expect(err).To(BeNil()) Expect(page1).To(HaveLen(2)) - page2, err := repo.GetAll(ctx, model.QueryOptions{Max: 2, Offset: 2, Sort: "id"}) + page2, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 2, Sort: "id"}) Expect(err).To(BeNil()) Expect(page2).To(HaveLen(1)) }) @@ -119,10 +112,10 @@ var _ = Describe("PluginRepository", func() { Enabled: false, } - err := repo.Put(ctx, plugin) + err := repo.Put(plugin) Expect(err).To(BeNil()) - saved, err := repo.Get(ctx, plugin.ID) + saved, err := repo.Get(plugin.ID) Expect(err).To(BeNil()) Expect(saved.Path).To(Equal(plugin.Path)) Expect(saved.Manifest).To(Equal(plugin.Manifest)) @@ -140,15 +133,15 @@ var _ = Describe("PluginRepository", func() { SHA256: "original", Enabled: false, } - _ = repo.Put(ctx, plugin) + _ = repo.Put(plugin) plugin.Enabled = true plugin.Config = `{"new":"config"}` plugin.SHA256 = "updated" - err := repo.Put(ctx, plugin) + err := repo.Put(plugin) Expect(err).To(BeNil()) - saved, err := repo.Get(ctx, plugin.ID) + saved, err := repo.Get(plugin.ID) Expect(err).To(BeNil()) Expect(saved.Enabled).To(BeTrue()) Expect(saved.Config).To(Equal(`{"new":"config"}`)) @@ -163,10 +156,10 @@ var _ = Describe("PluginRepository", func() { SHA256: "hash", LastError: "failed to load: missing export", } - err := repo.Put(ctx, plugin) + err := repo.Put(plugin) Expect(err).To(BeNil()) - saved, err := repo.Get(ctx, plugin.ID) + saved, err := repo.Get(plugin.ID) Expect(err).To(BeNil()) Expect(saved.LastError).To(Equal("failed to load: missing export")) }) @@ -177,7 +170,7 @@ var _ = Describe("PluginRepository", func() { Manifest: "{}", SHA256: "hash", } - err := repo.Put(ctx, plugin) + err := repo.Put(plugin) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ID cannot be empty")) }) @@ -185,14 +178,14 @@ var _ = Describe("PluginRepository", func() { Describe("ClearErrors", func() { It("clears last_error on all plugins with errors", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "ok-plugin", Path: "/plugins/ok.wasm", Manifest: "{}", SHA256: "h1"}) - _ = repo.Put(ctx, &model.Plugin{ID: "err-plugin-1", Path: "/plugins/e1.wasm", Manifest: "{}", SHA256: "h2", LastError: "incompatible version"}) - _ = repo.Put(ctx, &model.Plugin{ID: "err-plugin-2", Path: "/plugins/e2.wasm", Manifest: "{}", SHA256: "h3", LastError: "missing export"}) + _ = repo.Put(&model.Plugin{ID: "ok-plugin", Path: "/plugins/ok.wasm", Manifest: "{}", SHA256: "h1"}) + _ = repo.Put(&model.Plugin{ID: "err-plugin-1", Path: "/plugins/e1.wasm", Manifest: "{}", SHA256: "h2", LastError: "incompatible version"}) + _ = repo.Put(&model.Plugin{ID: "err-plugin-2", Path: "/plugins/e2.wasm", Manifest: "{}", SHA256: "h3", LastError: "missing export"}) - err := repo.ClearErrors(ctx) + err := repo.ClearErrors() Expect(err).To(BeNil()) - all, err := repo.GetAll(ctx) + all, err := repo.GetAll() Expect(err).To(BeNil()) for _, p := range all { Expect(p.LastError).To(BeEmpty(), "plugin %s should have no error", p.ID) @@ -200,63 +193,52 @@ var _ = Describe("PluginRepository", func() { }) It("succeeds when no plugins have errors", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"}) + _ = repo.Put(&model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"}) - err := repo.ClearErrors(ctx) + err := repo.ClearErrors() Expect(err).To(BeNil()) }) - - It("does not need the write lock when no plugins have errors", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"}) - conn, err := db.Db().Conn(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(conn.Close) - _, err = conn.ExecContext(GinkgoT().Context(), "BEGIN IMMEDIATE") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { _, _ = conn.ExecContext(context.Background(), "ROLLBACK") }) - - Expect(repo.ClearErrors(ctx)).To(Succeed()) - }) }) }) Describe("Regular User", func() { BeforeEach(func() { + ctx := GinkgoT().Context() ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false}) - repo = NewPluginRepository(GetDBXBuilder()) + repo = NewPluginRepository(ctx, GetDBXBuilder()) }) Describe("CountAll", func() { It("fails to count items", func() { - _, err := repo.CountAll(ctx) + _, err := repo.CountAll() Expect(err).To(Equal(rest.ErrPermissionDenied)) }) }) Describe("Delete", func() { It("fails to delete items", func() { - err := repo.Delete(ctx, "any-id") + err := repo.Delete("any-id") Expect(err).To(Equal(rest.ErrPermissionDenied)) }) }) Describe("Get", func() { It("fails to get items", func() { - _, err := repo.Get(ctx, "any-id") + _, err := repo.Get("any-id") Expect(err).To(Equal(rest.ErrPermissionDenied)) }) }) Describe("GetAll", func() { It("fails to get all items", func() { - _, err := repo.GetAll(ctx) + _, err := repo.GetAll() Expect(err).To(Equal(rest.ErrPermissionDenied)) }) }) Describe("Put", func() { It("fails to create/update item", func() { - err := repo.Put(ctx, &model.Plugin{ + err := repo.Put(&model.Plugin{ ID: "user-create", Path: "/plugins/create.wasm", Manifest: "{}", diff --git a/persistence/property_repository.go b/persistence/property_repository.go index 29bb2b564..14f9051f7 100644 --- a/persistence/property_repository.go +++ b/persistence/property_repository.go @@ -13,16 +13,17 @@ type propertyRepository struct { sqlRepository } -func NewPropertyRepository(db dbx.Builder) model.PropertyRepository { +func NewPropertyRepository(ctx context.Context, db dbx.Builder) model.PropertyRepository { r := &propertyRepository{} + r.ctx = ctx r.db = db r.tableName = "property" return r } -func (r propertyRepository) Put(ctx context.Context, id string, value string) error { +func (r propertyRepository) Put(id string, value string) error { update := Update(r.tableName).Set("value", value).Where(Eq{"id": id}) - count, err := r.executeSQL(ctx, update) + count, err := r.executeSQL(update) if err != nil { return err } @@ -30,24 +31,24 @@ func (r propertyRepository) Put(ctx context.Context, id string, value string) er return nil } insert := Insert(r.tableName).Columns("id", "value").Values(id, value) - _, err = r.executeSQL(ctx, insert) + _, err = r.executeSQL(insert) return err } -func (r propertyRepository) Get(ctx context.Context, id string) (string, error) { +func (r propertyRepository) Get(id string) (string, error) { sel := Select("value").From(r.tableName).Where(Eq{"id": id}) resp := struct { Value string }{} - err := r.queryOne(ctx, sel, &resp) + err := r.queryOne(sel, &resp) if err != nil { return "", err } return resp.Value, nil } -func (r propertyRepository) DefaultGet(ctx context.Context, id string, defaultValue string) (string, error) { - value, err := r.Get(ctx, id) +func (r propertyRepository) DefaultGet(id string, defaultValue string) (string, error) { + value, err := r.Get(id) if errors.Is(err, model.ErrNotFound) { return defaultValue, nil } @@ -57,6 +58,6 @@ func (r propertyRepository) DefaultGet(ctx context.Context, id string, defaultVa return value, nil } -func (r propertyRepository) Delete(ctx context.Context, id string) error { - return r.delete(ctx, Eq{"id": id}) +func (r propertyRepository) Delete(id string) error { + return r.delete(Eq{"id": id}) } diff --git a/persistence/property_repository_test.go b/persistence/property_repository_test.go index 880b315ec..3a0495e9f 100644 --- a/persistence/property_repository_test.go +++ b/persistence/property_repository_test.go @@ -10,27 +10,25 @@ import ( ) var _ = Describe("Property Repository", func() { - var ctx context.Context var pr model.PropertyRepository BeforeEach(func() { - ctx = log.NewContext(GinkgoT().Context()) - pr = NewPropertyRepository(GetDBXBuilder()) + pr = NewPropertyRepository(log.NewContext(context.TODO()), GetDBXBuilder()) }) It("saves and restore a new property", func() { id := "1" value := "a_value" - Expect(pr.Put(ctx, id, value)).To(BeNil()) - Expect(pr.Get(ctx, id)).To(Equal("a_value")) + Expect(pr.Put(id, value)).To(BeNil()) + Expect(pr.Get(id)).To(Equal("a_value")) }) It("updates a property", func() { - Expect(pr.Put(ctx, "1", "another_value")).To(BeNil()) - Expect(pr.Get(ctx, "1")).To(Equal("another_value")) + Expect(pr.Put("1", "another_value")).To(BeNil()) + Expect(pr.Get("1")).To(Equal("another_value")) }) It("returns a default value if property does not exist", func() { - Expect(pr.DefaultGet(ctx, "2", "default")).To(Equal("default")) + Expect(pr.DefaultGet("2", "default")).To(Equal("default")) }) }) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index b5d4f3a07..a073643db 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -2,11 +2,11 @@ package persistence import ( "context" + "errors" "time" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/pocketbase/dbx" @@ -16,8 +16,9 @@ type radioRepository struct { sqlRepository } -func NewRadioRepository(db dbx.Builder) model.RadioRepository { +func NewRadioRepository(ctx context.Context, db dbx.Builder) model.RadioRepository { r := &radioRepository{} + r.ctx = ctx r.db = db r.registerModel(&model.Radio{}, map[string]filterFunc{ "name": containsFilter("name"), @@ -25,65 +26,40 @@ func NewRadioRepository(db dbx.Builder) model.RadioRepository { return r } -func (r *radioRepository) isPermitted(ctx context.Context) bool { - user := loggedUser(ctx) +func (r *radioRepository) isPermitted() bool { + user := loggedUser(r.ctx) return user.IsAdmin } -func (r *radioRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - sql := r.newSelect(ctx) - return r.count(ctx, sql, options...) +func (r *radioRepository) CountAll(options ...model.QueryOptions) (int64, error) { + sql := r.newSelect() + return r.count(sql, options...) } -// Exists needs no library or ownership filter: radios are visible to every user. -func (r *radioRepository) Exists(ctx context.Context, id string) (bool, error) { - return r.exists(ctx, Eq{"id": id}) -} - -func (r *radioRepository) Delete(ctx context.Context, ids ...string) error { - if !r.isPermitted(ctx) { +func (r *radioRepository) Delete(id string) error { + if !r.isPermitted() { return rest.ErrPermissionDenied } - for _, id := range ids { - if err := r.deleteByID(ctx, id); err != nil { - return err - } - } - return nil + return r.delete(Eq{"id": id}) } -func (r *radioRepository) Get(ctx context.Context, id string) (*model.Radio, error) { - sel := r.newSelect(ctx).Where(Eq{"id": id}).Columns("*") +func (r *radioRepository) Get(id string) (*model.Radio, error) { + sel := r.newSelect().Where(Eq{"id": id}).Columns("*") res := model.Radio{} - err := r.queryOne(ctx, sel, &res) - if err != nil { - return &res, err - } - list := model.Radios{res} - r.hydrateArtwork(ctx, list) - return &list[0], nil + err := r.queryOne(sel, &res) + return &res, err } -func (r *radioRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Radios, error) { - sel := r.newSelect(ctx, options...).Columns("*") +func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, error) { + sel := r.newSelect(options...).Columns("*") res := model.Radios{} - err := r.queryAll(ctx, sel, &res) - if err != nil { - return res, err - } - r.hydrateArtwork(ctx, res) - return res, nil + err := r.queryAll(sel, &res) + return res, err } -// hydrateArtwork fills each radio's ImageHash/ImageAbsent from one batched item_artwork lookup. -func (r *radioRepository) hydrateArtwork(ctx context.Context, radios model.Radios) { - hydrateItems(ctx, r.db, model.KindRadioArtwork, radios, - func(rd *model.Radio) (string, *model.ItemImage) { return rd.ID, &rd.ItemImage }) -} - -func (r *radioRepository) Put(ctx context.Context, radio *model.Radio, colsToUpdate ...string) error { - if !r.isPermitted(ctx) { +func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { + if !r.isPermitted() { return rest.ErrPermissionDenied } @@ -95,49 +71,55 @@ func (r *radioRepository) Put(ctx context.Context, radio *model.Radio, colsToUpd if len(colsToUpdate) > 0 { colsToUpdate = append(colsToUpdate, "UpdatedAt") } - _, err := r.put(ctx, radio.ID, radio, colsToUpdate...) - if err != nil { - return err - } - // Enqueue artwork resolution for the created/updated radio at Bump priority so a new - // radio's cover resolves proactively. Never fails the save. - item := model.ArtworkQueueItem{ItemKind: model.KindRadioArtwork.Prefix(), ItemID: radio.ID, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBump} - if err := NewArtworkQueueRepository(r.db).Enqueue(ctx, item); err != nil { - log.Warn(ctx, "could not enqueue radio artwork", "id", radio.ID, err) - } - return nil + _, err := r.put(radio.ID, radio, colsToUpdate...) + return err } -func (r *radioRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *radioRepository) Read(ctx context.Context, id string) (*model.Radio, error) { - return r.Get(ctx, id) +func (r *radioRepository) EntityName() string { + return "radio" } -func (r *radioRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Radio, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *radioRepository) NewInstance() any { + return &model.Radio{} } -func (r *radioRepository) Save(ctx context.Context, t *model.Radio) (string, error) { - if !r.isPermitted(ctx) { +func (r *radioRepository) Read(id string) (any, error) { + return r.Get(id) +} + +func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *radioRepository) Save(entity any) (string, error) { + t := entity.(*model.Radio) + if !r.isPermitted() { return "", rest.ErrPermissionDenied } - err := r.Put(ctx, t) + err := r.Put(t) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } return t.ID, err } -func (r *radioRepository) Update(ctx context.Context, id string, entity model.Radio, cols ...string) error { - t := &entity +func (r *radioRepository) Update(id string, entity any, cols ...string) error { + t := entity.(*model.Radio) t.ID = id - if !r.isPermitted(ctx) { + if !r.isPermitted() { return rest.ErrPermissionDenied } - return r.Put(ctx, t, cols...) + err := r.Put(t) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err } var _ model.RadioRepository = (*radioRepository)(nil) -var _ rest.Repository[model.Radio] = (*radioRepository)(nil) -var _ rest.Persistable[model.Radio] = (*radioRepository)(nil) +var _ rest.Repository = (*radioRepository)(nil) +var _ rest.Persistable = (*radioRepository)(nil) diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index aba776853..88a31ac49 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -11,30 +11,31 @@ import ( . "github.com/onsi/gomega" ) +var ( + NewId string = "123-456-789" +) + var _ = Describe("RadioRepository", func() { var repo model.RadioRepository - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) Describe("Admin User", func() { BeforeEach(func() { - ctx = request.WithUser(log.NewContext(ctx), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewRadioRepository(GetDBXBuilder()) - _ = repo.Put(ctx, &radioWithHomePage) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewRadioRepository(ctx, GetDBXBuilder()) + _ = repo.Put(&radioWithHomePage) }) AfterEach(func() { - all, _ := repo.GetAll(ctx) + all, _ := repo.GetAll() for _, radio := range all { - _ = repo.Delete(ctx, radio.ID) + _ = repo.Delete(radio.ID) } for i := range testRadios { - err := repo.Put(ctx, new(testRadios[i])) + r := testRadios[i] + err := repo.Put(&r) if err != nil { panic(err) } @@ -43,35 +44,31 @@ var _ = Describe("RadioRepository", func() { Describe("Count", func() { It("returns the number of radios in the DB", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) + Expect(repo.CountAll()).To(Equal(int64(2))) }) }) Describe("Delete", func() { It("deletes existing item", func() { - err := repo.Delete(ctx, radioWithHomePage.ID) + err := repo.Delete(radioWithHomePage.ID) Expect(err).To(BeNil()) - _, err = repo.Get(ctx, radioWithHomePage.ID) + _, err = repo.Get(radioWithHomePage.ID) Expect(err).To(MatchError(model.ErrNotFound)) }) - - It("errors when missing", func() { - Expect(repo.Delete(ctx, "notanid")).To(MatchError(model.ErrNotFound)) - }) }) Describe("Get", func() { It("returns an existing item", func() { - res, err := repo.Get(ctx, radioWithHomePage.ID) + res, err := repo.Get(radioWithHomePage.ID) Expect(err).To(BeNil()) Expect(res.ID).To(Equal(radioWithHomePage.ID)) }) It("errors when missing", func() { - _, err := repo.Get(ctx, "notanid") + _, err := repo.Get("notanid") Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -79,7 +76,7 @@ var _ = Describe("RadioRepository", func() { Describe("GetAll", func() { It("returns all items from the DB", func() { - all, err := repo.GetAll(ctx) + all, err := repo.GetAll() Expect(err).To(BeNil()) Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID)) Expect(all[1].ID).To(Equal(radioWithHomePage.ID)) @@ -88,7 +85,7 @@ var _ = Describe("RadioRepository", func() { Describe("Put", func() { It("successfully updates item", func() { - err := repo.Put(ctx, &model.Radio{ + err := repo.Put(&model.Radio{ ID: radioWithHomePage.ID, Name: "New Name", StreamUrl: "https://example.com:4533/app", @@ -96,81 +93,44 @@ var _ = Describe("RadioRepository", func() { Expect(err).To(BeNil()) - item, err := repo.Get(ctx, radioWithHomePage.ID) + item, err := repo.Get(radioWithHomePage.ID) Expect(err).To(BeNil()) Expect(item.HomePageUrl).To(Equal("")) }) It("successfully creates item", func() { - err := repo.Put(ctx, &model.Radio{ + err := repo.Put(&model.Radio{ Name: "New radio", StreamUrl: "https://example.com:4533/app", }) Expect(err).To(BeNil()) - Expect(repo.CountAll(ctx)).To(Equal(int64(3))) + Expect(repo.CountAll()).To(Equal(int64(3))) - all, err := repo.GetAll(ctx) + all, err := repo.GetAll() Expect(err).To(BeNil()) Expect(all[2].StreamUrl).To(Equal("https://example.com:4533/app")) }) - - It("enqueues artwork resolution for the saved radio", func() { - err := repo.Put(ctx, &model.Radio{ - Name: "Artwork radio", - StreamUrl: "https://example.com:4533/artwork", - }) - Expect(err).To(BeNil()) - - all, err := repo.GetAll(ctx) - Expect(err).To(BeNil()) - created := all[len(all)-1] - - queueRepo := NewArtworkQueueRepository(GetDBXBuilder()) - queued, err := queueRepo.DequeueBatch(ctx, 1000) - Expect(err).To(BeNil()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "ra"), - HaveField("ItemID", created.ID), - HaveField("Priority", model.ArtworkPriorityBump), - ))) - }) - }) - - Describe("Update", func() { - It("only writes the columns sent by the client", func() { - radio := radioWithHomePage - radio.UploadedImage = "cover.png" - Expect(repo.Put(ctx, &radio)).To(Succeed()) - - Expect(repo.Update(ctx, radio.ID, model.Radio{Name: "Renamed"}, "name")).To(Succeed()) - - item, err := repo.Get(ctx, radio.ID) - Expect(err).To(BeNil()) - Expect(item.Name).To(Equal("Renamed")) - Expect(item.UploadedImage).To(Equal("cover.png")) - Expect(item.StreamUrl).To(Equal(radio.StreamUrl)) - Expect(item.HomePageUrl).To(Equal(radio.HomePageUrl)) - }) }) }) Describe("Regular User", func() { BeforeEach(func() { - ctx = request.WithUser(log.NewContext(ctx), model.User{ID: "userid", UserName: "userid", IsAdmin: false}) - repo = NewRadioRepository(GetDBXBuilder()) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false}) + repo = NewRadioRepository(ctx, GetDBXBuilder()) }) Describe("Count", func() { It("returns the number of radios in the DB", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) + Expect(repo.CountAll()).To(Equal(int64(2))) }) }) Describe("Delete", func() { It("fails to delete items", func() { - err := repo.Delete(ctx, radioWithHomePage.ID) + err := repo.Delete(radioWithHomePage.ID) Expect(err).To(Equal(rest.ErrPermissionDenied)) }) @@ -178,14 +138,14 @@ var _ = Describe("RadioRepository", func() { Describe("Get", func() { It("returns an existing item", func() { - res, err := repo.Get(ctx, radioWithHomePage.ID) + res, err := repo.Get(radioWithHomePage.ID) - Expect(err).To(BeNil()) + Expect(err).To((BeNil())) Expect(res.ID).To(Equal(radioWithHomePage.ID)) }) It("errors when missing", func() { - _, err := repo.Get(ctx, "notanid") + _, err := repo.Get("notanid") Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -193,7 +153,7 @@ var _ = Describe("RadioRepository", func() { Describe("GetAll", func() { It("returns all items from the DB", func() { - all, err := repo.GetAll(ctx) + all, err := repo.GetAll() Expect(err).To(BeNil()) Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID)) Expect(all[1].ID).To(Equal(radioWithHomePage.ID)) @@ -202,7 +162,7 @@ var _ = Describe("RadioRepository", func() { Describe("Put", func() { It("fails to update item", func() { - err := repo.Put(ctx, &model.Radio{ + err := repo.Put(&model.Radio{ ID: radioWithHomePage.ID, Name: "New Name", StreamUrl: "https://example.com:4533/app", diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go index 0daf2b0e7..3cfb836bf 100644 --- a/persistence/scrobble_buffer_repository.go +++ b/persistence/scrobble_buffer_repository.go @@ -29,14 +29,15 @@ func (t *dbScrobbleBuffer) PostScan() error { return nil } -func NewScrobbleBufferRepository(db dbx.Builder) model.ScrobbleBufferRepository { +func NewScrobbleBufferRepository(ctx context.Context, db dbx.Builder) model.ScrobbleBufferRepository { r := &scrobbleBufferRepository{} + r.ctx = ctx r.db = db r.tableName = "scrobble_buffer" return r } -func (r *scrobbleBufferRepository) UserIDs(ctx context.Context, service string) ([]string, error) { +func (r *scrobbleBufferRepository) UserIDs(service string) ([]string, error) { sql := Select().Columns("user_id"). From(r.tableName). Where(And{ @@ -45,11 +46,11 @@ func (r *scrobbleBufferRepository) UserIDs(ctx context.Context, service string) GroupBy("user_id"). OrderBy("count(*)") var userIds []string - err := r.queryAllSlice(ctx, sql, &userIds) + err := r.queryAllSlice(sql, &userIds) return userIds, err } -func (r *scrobbleBufferRepository) Enqueue(ctx context.Context, service, userId, mediaFileId string, playTime time.Time) error { +func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, playTime time.Time) error { ins := Insert(r.tableName).SetMap(map[string]any{ "id": id.NewRandom(), "user_id": userId, @@ -58,11 +59,11 @@ func (r *scrobbleBufferRepository) Enqueue(ctx context.Context, service, userId, "play_time": playTime, "enqueue_time": time.Now(), }) - _, err := r.executeSQL(ctx, ins) + _, err := r.executeSQL(ins) return err } -func (r *scrobbleBufferRepository) Next(ctx context.Context, service string, userId string) (*model.ScrobbleEntry, error) { +func (r *scrobbleBufferRepository) Next(service string, userId string) (*model.ScrobbleEntry, error) { // Put `s.*` last or else m.id overrides s.id sql := Select().Columns("m.*, s.*"). From(r.tableName+" s"). @@ -74,30 +75,26 @@ func (r *scrobbleBufferRepository) Next(ctx context.Context, service string, use OrderBy("play_time", "s.rowid").Limit(1) var res dbScrobbleBuffer - err := r.queryOne(ctx, sql, &res) + err := r.queryOne(sql, &res) if errors.Is(err, model.ErrNotFound) { return nil, nil } if err != nil { return nil, err } - res.ScrobbleEntry.Participants, err = r.getParticipants(ctx, &res.ScrobbleEntry.MediaFile) + res.ScrobbleEntry.Participants, err = r.getParticipants(&res.ScrobbleEntry.MediaFile) if err != nil { return nil, err } return res.ScrobbleEntry, nil } -func (r *scrobbleBufferRepository) Dequeue(ctx context.Context, entry *model.ScrobbleEntry) error { - return r.delete(ctx, Eq{"id": entry.ID}) +func (r *scrobbleBufferRepository) Dequeue(entry *model.ScrobbleEntry) error { + return r.delete(Eq{"id": entry.ID}) } -func (r *scrobbleBufferRepository) Discard(ctx context.Context, service string) error { - return r.delete(ctx, Eq{"service": service}) -} - -func (r *scrobbleBufferRepository) Length(ctx context.Context) (int64, error) { - return r.count(ctx, Select()) +func (r *scrobbleBufferRepository) Length() (int64, error) { + return r.count(Select()) } var _ model.ScrobbleBufferRepository = (*scrobbleBufferRepository)(nil) diff --git a/persistence/scrobble_buffer_repository_test.go b/persistence/scrobble_buffer_repository_test.go index 38b0e4e63..edf59ce49 100644 --- a/persistence/scrobble_buffer_repository_test.go +++ b/persistence/scrobble_buffer_repository_test.go @@ -16,7 +16,6 @@ import ( var _ = Describe("ScrobbleBufferRepository", func() { var scrobble model.ScrobbleBufferRepository var rawRepo sqlRepository - var ctx context.Context enqueueTime := time.Date(2025, 01, 01, 00, 00, 00, 00, time.Local) var ids []string @@ -33,16 +32,17 @@ var _ = Describe("ScrobbleBufferRepository", func() { "play_time": playTime, "enqueue_time": enqueueTime, }) - _, err := rawRepo.executeSQL(ctx, ins) + _, err := rawRepo.executeSQL(ins) Expect(err).ToNot(HaveOccurred()) } BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) + ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) db := GetDBXBuilder() - scrobble = NewScrobbleBufferRepository(db) + scrobble = NewScrobbleBufferRepository(ctx, db) rawRepo = sqlRepository{ + ctx: ctx, tableName: "scrobble_buffer", db: db, } @@ -51,14 +51,14 @@ var _ = Describe("ScrobbleBufferRepository", func() { AfterEach(func() { del := squirrel.Delete(rawRepo.tableName) - _, err := rawRepo.executeSQL(ctx, del) + _, err := rawRepo.executeSQL(del) Expect(err).ToNot(HaveOccurred()) }) Describe("Without data", func() { Describe("Count", func() { It("returns zero when empty", func() { - count, err := scrobble.Length(ctx) + count, err := scrobble.Length() Expect(err).ToNot(HaveOccurred()) Expect(count).To(BeZero()) }) @@ -66,10 +66,10 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("Dequeue", func() { It("is a no-op when deleting a nonexistent item", func() { - err := scrobble.Dequeue(ctx, &model.ScrobbleEntry{ID: "fake"}) + err := scrobble.Dequeue(&model.ScrobbleEntry{ID: "fake"}) Expect(err).ToNot(HaveOccurred()) - count, err := scrobble.Length(ctx) + count, err := scrobble.Length() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(0))) }) @@ -77,7 +77,7 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("Next", func() { It("should not fail with no item for the service", func() { - entry, err := scrobble.Next(ctx, "fake", "userid") + entry, err := scrobble.Next("fake", "userid") Expect(entry).To(BeNil()) Expect(err).ToNot(HaveOccurred()) }) @@ -85,7 +85,7 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("UserIds", func() { It("should return empty list with no data", func() { - ids, err := scrobble.UserIDs(ctx, "service") + ids, err := scrobble.UserIDs("service") Expect(err).ToNot(HaveOccurred()) Expect(ids).To(BeEmpty()) }) @@ -107,7 +107,7 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("Count", func() { It("Returns count when populated", func() { - count, err := scrobble.Length(ctx) + count, err := scrobble.Length() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(4))) }) @@ -115,23 +115,23 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("Dequeue", func() { It("is a no-op when deleting a nonexistent item", func() { - err := scrobble.Dequeue(ctx, &model.ScrobbleEntry{ID: "fake"}) + err := scrobble.Dequeue(&model.ScrobbleEntry{ID: "fake"}) Expect(err).ToNot(HaveOccurred()) - count, err := scrobble.Length(ctx) + count, err := scrobble.Length() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(4))) }) It("deletes an item when specified properly", func() { - err := scrobble.Dequeue(ctx, &model.ScrobbleEntry{ID: ids[3]}) + err := scrobble.Dequeue(&model.ScrobbleEntry{ID: ids[3]}) Expect(err).ToNot(HaveOccurred()) - count, err := scrobble.Length(ctx) + count, err := scrobble.Length() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(3))) - entry, err := scrobble.Next(ctx, "b", "2222") + entry, err := scrobble.Next("b", "2222") Expect(err).ToNot(HaveOccurred()) Expect(entry).To(BeNil()) }) @@ -141,14 +141,14 @@ var _ = Describe("ScrobbleBufferRepository", func() { DescribeTable("enqueues an item properly", func(service, userId, fileId string, playTime time.Time) { now := time.Now() - err := scrobble.Enqueue(ctx, service, userId, fileId, playTime) + err := scrobble.Enqueue(service, userId, fileId, playTime) Expect(err).ToNot(HaveOccurred()) - count, err := scrobble.Length(ctx) + count, err := scrobble.Length() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(5))) - entry, err := scrobble.Next(ctx, service, userId) + entry, err := scrobble.Next(service, userId) Expect(err).ToNot(HaveOccurred()) Expect(entry).ToNot(BeNil()) @@ -165,7 +165,7 @@ var _ = Describe("ScrobbleBufferRepository", func() { Describe("Next", func() { DescribeTable("Returns the next item when populated", func(service, id string, playTime time.Time, fileId, artistId string) { - entry, err := scrobble.Next(ctx, service, id) + entry, err := scrobble.Next(service, id) Expect(err).ToNot(HaveOccurred()) Expect(entry).ToNot(BeNil()) @@ -191,37 +191,15 @@ var _ = Describe("ScrobbleBufferRepository", func() { }) - Describe("Discard", func() { - It("deletes all entries for a service, keeping other services intact", func() { - Expect(scrobble.Discard(ctx, "a")).To(Succeed()) - - count, err := scrobble.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(1))) - - entry, err := scrobble.Next(ctx, "b", "2222") - Expect(err).ToNot(HaveOccurred()) - Expect(entry).ToNot(BeNil()) - }) - - It("is a no-op for a service without entries", func() { - Expect(scrobble.Discard(ctx, "nonexistent")).To(Succeed()) - - count, err := scrobble.Length(ctx) - 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(ctx, "a") + ids, err := scrobble.UserIDs("a") Expect(err).ToNot(HaveOccurred()) Expect(ids).To(Equal([]string{"2222", "userid"})) }) It("should return for a different service", func() { - ids, err := scrobble.UserIDs(ctx, "b") + ids, err := scrobble.UserIDs("b") Expect(err).ToNot(HaveOccurred()) Expect(ids).To(Equal([]string{"2222"})) }) diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 3d9882b99..219a48198 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -5,7 +5,6 @@ import ( "time" . "github.com/Masterminds/squirrel" - "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" ) @@ -14,77 +13,22 @@ 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(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - user := loggedUser(ctx) - - return r.newSelect(ctx, options...). - Columns("id", "media_file_id", "submission_time"). - Where(Eq{"scrobbles.user_id": user.ID}) -} - -func NewScrobbleRepository(db dbx.Builder) model.ScrobbleRepository { +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 } -func (r *scrobbleRepository) RecordScrobble(ctx context.Context, mediaFileID string, submissionTime time.Time) error { - userID := loggedUser(ctx).ID +func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error { + userID := loggedUser(r.ctx).ID values := map[string]any{ "media_file_id": mediaFileID, "user_id": userID, "submission_time": submissionTime.Unix(), } insert := Insert(r.tableName).SetMap(values) - _, err := r.executeSQL(ctx, insert) + _, err := r.executeSQL(insert) return err } - -func (r *scrobbleRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - return r.count(ctx, r.baseQuery(ctx), options...) -} - -func (r *scrobbleRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) -} - -func (r *scrobbleRepository) Get(ctx context.Context, id string) (*model.Scrobble, error) { - sel := r.baseQuery(ctx).Where(Eq{"id": id}) - var res model.Scrobble - err := r.queryOne(ctx, sel, &res) - return &res, err -} - -func (r *scrobbleRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Scrobbles, error) { - sel := r.baseQuery(ctx, options...) - var scrobbles model.Scrobbles - err := r.queryAll(ctx, sel, &scrobbles) - return scrobbles, err -} - -func (r *scrobbleRepository) Read(ctx context.Context, id string) (*model.Scrobble, error) { - return r.Get(ctx, id) -} - -func (r *scrobbleRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Scrobble, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) -} - -var _ model.ScrobbleRepository = (*scrobbleRepository)(nil) -var _ rest.Repository[model.Scrobble] = (*scrobbleRepository)(nil) diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go index 860b1ad02..d43848d03 100644 --- a/persistence/scrobble_repository_test.go +++ b/persistence/scrobble_repository_test.go @@ -4,7 +4,6 @@ import ( "context" "time" - "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" @@ -16,32 +15,32 @@ 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(db) - - rawRepo = sqlRepository{ - 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() @@ -64,7 +63,7 @@ var _ = Describe("ScrobbleRepository", func() { }).Execute() Expect(err).ToNot(HaveOccurred()) - err = repo.RecordScrobble(ctx, fileID, submissionTime) + err = repo.RecordScrobble(fileID, submissionTime) Expect(err).ToNot(HaveOccurred()) // Verify insertion @@ -82,137 +81,4 @@ 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(GetDBXBuilder()) - }) - - Describe("Count", func() { - It("Returns the number of scrobbles in the DB for admin user", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) - }) - - It("returns scrobbles in a range", func() { - Expect(repo.CountAll(ctx, 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(ctx, "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(ctx, "2") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("does not return a scrobble that does not exist", func() { - _, err := repo.Get(ctx, "444") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("GetAll", func() { - It("returns all scrobbles in reverse order", func() { - scrobbles, err := repo.GetAll(ctx, 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(ctx, 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(GetDBXBuilder()) - }) - - Describe("Count", func() { - It("Returns the number of scrobbles in the DB for admin user", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(1))) - }) - - It("returns scrobbles in a range", func() { - Expect(repo.CountAll(ctx, 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(ctx, "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(ctx, "1") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("does not return a scrobble that does not exist", func() { - _, err := repo.Get(ctx, "444") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("GetAll", func() { - It("returns all scrobbles in reverse order", func() { - scrobbles, err := repo.GetAll(ctx, 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(ctx, 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 6dd9c3d85..415109640 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "errors" "fmt" "strings" "time" @@ -18,8 +19,9 @@ type shareRepository struct { sqlRepository } -func NewShareRepository(db dbx.Builder) model.ShareRepository { +func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareRepository { r := &shareRepository{} + r.ctx = ctx r.db = db r.registerModel(&model.Share{}, nil) r.setSortMappings(map[string]string{ @@ -28,40 +30,69 @@ func NewShareRepository(db dbx.Builder) model.ShareRepository { return r } -func (r *shareRepository) Delete(ctx context.Context, ids ...string) error { - return r.deleteOwnedAll(ctx, ids...) +// 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) selectShare(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - return r.newSelect(ctx, options...).Join("user u on u.id = share.user_id"). - Columns("share.*", "user_name as username"). - Where(r.addRestriction(ctx)) +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 } -func (r *shareRepository) Exists(ctx context.Context, id string) (bool, error) { - return r.exists(ctx, r.addRestriction(ctx, And{Eq{"id": 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") } -func (r *shareRepository) Get(ctx context.Context, id string) (*model.Share, error) { - sel := r.selectShare(ctx).Where(Eq{"share.id": id}) +func (r *shareRepository) Exists(id string) (bool, error) { + return r.exists(Eq{"id": id}) +} + +func (r *shareRepository) Get(id string) (*model.Share, error) { + sel := r.selectShare().Where(Eq{"share.id": id}) var res model.Share - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) if err != nil { return nil, err } - err = r.loadMedia(ctx, &res) + err = r.loadMedia(&res) return &res, err } -func (r *shareRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Shares, error) { - sq := r.selectShare(ctx, options...) +func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, error) { + sq := r.selectShare(options...) res := model.Shares{} - err := r.queryAll(ctx, sq, &res) + err := r.queryAll(sq, &res) if err != nil { return nil, err } for i := range res { - err = r.loadMedia(ctx, &res[i]) + err = r.loadMedia(&res[i]) if err != nil { return nil, fmt.Errorf("error loading media for share %s: %w", res[i].ID, err) } @@ -69,7 +100,8 @@ func (r *shareRepository) GetAll(ctx context.Context, options ...model.QueryOpti return res, err } -func (r *shareRepository) loadMedia(ctx context.Context, share *model.Share) error { +func (r *shareRepository) loadMedia(share *model.Share) error { + var err error ids := strings.Split(share.ResourceIDs, ",") if len(ids) == 0 { return nil @@ -77,70 +109,47 @@ func (r *shareRepository) loadMedia(ctx context.Context, share *model.Share) err noMissing := func(cond Sqlizer) Sqlizer { return And{cond, Eq{"missing": false}} } - // Load as the share owner so their library access is applied, whoever renders the share. - ownerCtx, err := r.ownerContext(ctx, share) - if err != nil { - return err - } switch share.ResourceType { case "artist": - // Match by album-artist participation, not the deprecated album_artist_id - // column (first album artist only), so co-album-artists are included too. - albumRepo := NewAlbumRepository(r.db) - share.Albums, err = albumRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"}) + albumRepo := NewAlbumRepository(r.ctx, r.db) + share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"}) if err != nil { return err } - mfRepo := NewMediaFileRepository(r.db) - share.Tracks, err = mfRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"}) + mfRepo := NewMediaFileRepository(r.ctx, r.db) + share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"}) return err case "album": - albumRepo := NewAlbumRepository(r.db) - share.Albums, err = albumRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})}) + albumRepo := NewAlbumRepository(r.ctx, r.db) + share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})}) if err != nil { return err } - mfRepo := NewMediaFileRepository(r.db) - share.Tracks, err = mfRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"}) + mfRepo := NewMediaFileRepository(r.ctx, r.db) + share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"}) return err case "playlist": - plsRepo := NewPlaylistRepository(r.db) - // 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(ownerCtx, ids[0], true) - if trackRepo == nil { - return nil - } - tracks, err := trackRepo.GetAll(ownerCtx, model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) + // Create a context with a fake admin user, to be able to access all playlists + ctx := request.WithUser(r.ctx, model.User{IsAdmin: true}) + plsRepo := NewPlaylistRepository(ctx, r.db) + tracks, err := plsRepo.Tracks(ids[0], true).GetAll(model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) if err != nil { return err } - share.Tracks = tracks.MediaFiles() + if len(tracks) >= 0 { + share.Tracks = tracks.MediaFiles() + } return nil case "media_file": - mfRepo := NewMediaFileRepository(r.db) - tracks, err := mfRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})}) + mfRepo := NewMediaFileRepository(r.ctx, r.db) + tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})}) share.Tracks = sortByIdPosition(tracks, ids) return err } - log.Warn(ctx, "Unsupported Share ResourceType", "share", share.ID, "resourceType", share.ResourceType) + log.Warn(r.ctx, "Unsupported Share ResourceType", "share", share.ID, "resourceType", share.ResourceType) return nil } -// ownerContext returns a context scoped to the share owner, so repository -// queries apply the owner's library access when a public share is rendered. -func (r *shareRepository) ownerContext(ctx context.Context, share *model.Share) (context.Context, error) { - owner, err := NewUserRepository(r.db).Get(ctx, share.UserID) - if err != nil { - return nil, fmt.Errorf("loading share owner %q: %w", share.UserID, err) - } - if owner == nil { - return nil, fmt.Errorf("share owner %q not found", share.UserID) - } - return request.WithUser(ctx, *owner), nil -} - func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { m := map[string]int{} for i, mf := range mfs { @@ -155,51 +164,67 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { return sorted } -func (r *shareRepository) Update(ctx context.Context, id string, entity model.Share, cols ...string) error { - s := &entity +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() - if len(cols) > 0 { - cols = append(cols, "updated_at") + cols = append(cols, "updated_at") + _, err := r.put(id, s, cols...) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound } - return r.updateOwned(ctx, id, s, cols...) + return err } -func (r *shareRepository) Save(ctx context.Context, s *model.Share) (string, error) { +func (r *shareRepository) Save(entity any) (string, error) { + s := entity.(*model.Share) // TODO Validate record - // Owner is server-managed: for an authenticated request, never trust a - // client-supplied UserID, as it drives the share's library-access context. - u := loggedUser(ctx) - if u.ID != invalidUserId || s.UserID == "" { + u := loggedUser(r.ctx) + if s.UserID == "" { s.UserID = u.ID } s.CreatedAt = time.Now() s.UpdatedAt = time.Now() - return r.put(ctx, s.ID, s) + id, err := r.put(s.ID, s) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + return id, err } -func (r *shareRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - return r.count(ctx, r.selectShare(ctx), options...) +func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error) { + return r.count(r.selectShare(), options...) } -func (r *shareRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *shareRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *shareRepository) Read(ctx context.Context, id string) (*model.Share, error) { - sel := r.selectShare(ctx).Where(Eq{"share.id": id}) +func (r *shareRepository) EntityName() string { + return "share" +} + +func (r *shareRepository) NewInstance() any { + return &model.Share{} +} + +func (r *shareRepository) Read(id string) (any, error) { + sel := r.selectShare().Where(Eq{"share.id": id}) var res model.Share - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *shareRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Share, error) { - sq := r.selectShare(ctx, r.parseRestOptions(ctx, options...)) +func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + sq := r.selectShare(r.parseRestOptions(r.ctx, options...)) res := model.Shares{} - err := r.queryAll(ctx, sq, &res) + err := r.queryAll(sq, &res) return res, err } var _ model.ShareRepository = (*shareRepository)(nil) -var _ rest.Repository[model.Share] = (*shareRepository)(nil) -var _ rest.Persistable[model.Share] = (*shareRepository)(nil) +var _ rest.Repository = (*shareRepository)(nil) +var _ rest.Persistable = (*shareRepository)(nil) diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 33b5e2110..6988f323f 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -4,7 +4,6 @@ import ( "context" "time" - "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" @@ -21,12 +20,12 @@ var _ = Describe("ShareRepository", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - repo = NewShareRepository(GetDBXBuilder()) + ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) + repo = NewShareRepository(ctx, GetDBXBuilder()) // Insert the admin user into the database (required for foreign key constraint) - ur := NewUserRepository(GetDBXBuilder()) - err := ur.Put(ctx, &adminUser) + ur := NewUserRepository(ctx, GetDBXBuilder()) + err := ur.Put(&adminUser) Expect(err).ToNot(HaveOccurred()) // Clean up shares @@ -37,15 +36,9 @@ var _ = Describe("ShareRepository", func() { Describe("Headless Access", func() { Context("Repository creation and basic operations", func() { - var headlessCtx context.Context - - BeforeEach(func() { - headlessCtx = GinkgoT().Context() - }) - It("should create repository successfully with no user context", func() { // Create repository with no user context (headless) - headlessRepo := NewShareRepository(GetDBXBuilder()) + headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) Expect(headlessRepo).ToNot(BeNil()) }) @@ -67,8 +60,8 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should see all shares - headlessRepo := NewShareRepository(GetDBXBuilder()) - shares, err := headlessRepo.GetAll(headlessCtx) + headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + shares, err := headlessRepo.GetAll() Expect(err).ToNot(HaveOccurred()) found := false @@ -99,8 +92,8 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should be able to get the share - headlessRepo := NewShareRepository(GetDBXBuilder()) - share, err := headlessRepo.Get(headlessCtx, shareID) + headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + share, err := headlessRepo.Get(shareID) Expect(err).ToNot(HaveOccurred()) Expect(share.ID).To(Equal(shareID)) Expect(share.Description).To(Equal("Headless Get Share")) @@ -131,7 +124,7 @@ var _ = Describe("ShareRepository", func() { // The Get operation should work without SQL ambiguity errors // even if no albums are found - share, err := repo.Get(ctx, shareID) + share, err := repo.Get(shareID) Expect(err).ToNot(HaveOccurred()) Expect(share.ID).To(Equal(shareID)) // Albums array should be empty since we used non-existent album ID @@ -139,209 +132,6 @@ 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(GetDBXBuilder()) - otherLib = model.Library{ID: 0, Name: "Share Other Library", Path: "/share/other/lib"} - Expect(lr.Put(adminCtx, &otherLib)).To(Succeed()) - mr := NewMediaFileRepository(GetDBXBuilder()) - Expect(mr.Put(adminCtx, &model.MediaFile{ID: "share-other", LibraryID: otherLib.ID, Path: "s/other.mp3", Title: "ShareOther"})).To(Succeed()) - Expect(mr.Put(adminCtx, &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(GetDBXBuilder()) - Expect(ur.Put(adminCtx, &owner)).To(Succeed()) - Expect(ur.SetUserLibraries(adminCtx, 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(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(ownerCtx, 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(b) - _ = pr.Delete(adminCtx, plsID) - mr := NewMediaFileRepository(b).(*mediaFileRepository) - _, _ = mr.executeSQL(adminCtx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"share-other", "share-ok"}})) - lr := NewLibraryRepository(b).(*libraryRepository) - _ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID}) - _ = NewUserRepository(b).Delete(adminCtx, 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. - adminCtx := request.WithUser(ctx, adminUser) - adminRepo := NewShareRepository(GetDBXBuilder()) - share, err := adminRepo.Get(adminCtx, "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(GetDBXBuilder()) - privatePls := &model.Playlist{ID: privatePlsID, Name: "Private", OwnerID: adminUser.ID, Public: false} - privatePls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}}) - Expect(pr.Put(adminCtx, privatePls)).To(Succeed()) - DeferCleanup(func() { _ = pr.Delete(adminCtx, 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(GetDBXBuilder()) - share, err := adminRepo.Get(adminCtx, "share-private") - Expect(err).ToNot(HaveOccurred()) - Expect(share.Tracks).To(BeEmpty()) - }) - }) - - Describe("Artist, album and media file share library scoping", func() { - var otherLib model.Library - var owner model.User - const primaryID = "share-aa-primary" - const secondaryID = "share-aa-secondary" - - BeforeEach(func() { - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - b := GetDBXBuilder() - - // A second library the owner has no access to - lr := NewLibraryRepository(b) - otherLib = model.Library{ID: 0, Name: "Artist Share Other Library", Path: "/share/artist/other"} - Expect(lr.Put(adminCtx, &otherLib)).To(Succeed()) - - ar := NewArtistRepository(b) - Expect(createArtistWithLibrary(adminCtx, ar, &model.Artist{ID: primaryID, Name: "AA Primary", OrderArtistName: "aa primary"}, 1)).To(Succeed()) - Expect(createArtistWithLibrary(adminCtx, ar, &model.Artist{ID: secondaryID, Name: "AA Secondary", OrderArtistName: "aa secondary"}, 1)).To(Succeed()) - - // Secondary is a co-album-artist (not the first): album_artist_id points at - // primary, so the legacy-column filter would miss both tracks. - aaParticipants := model.Participants{model.RoleAlbumArtist: { - {Artist: model.Artist{ID: primaryID, Name: "AA Primary"}}, - {Artist: model.Artist{ID: secondaryID, Name: "AA Secondary"}}, - }} - alr := NewAlbumRepository(b) - Expect(alr.Put(ctx, &model.Album{ID: "art-album-ok", LibraryID: 1, Name: "Art Album OK", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) - Expect(alr.Put(ctx, &model.Album{ID: "art-album-other", LibraryID: otherLib.ID, Name: "Art Album Other", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) - - mr := NewMediaFileRepository(b) - Expect(mr.Put(adminCtx, &model.MediaFile{ID: "art-ok", LibraryID: 1, AlbumID: "art-album-ok", Path: "a/ok.mp3", Title: "ArtOK", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) - Expect(mr.Put(adminCtx, &model.MediaFile{ID: "art-other", LibraryID: otherLib.ID, AlbumID: "art-album-other", Path: "a/other.mp3", Title: "ArtOther", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) - - // Non-admin owner with access to library 1 only - owner = createUserWithLibraries("artist-share-owner", []int{1}) - ur := NewUserRepository(b) - Expect(ur.Put(adminCtx, &owner)).To(Succeed()) - Expect(ur.SetUserLibraries(adminCtx, owner.ID, []int{1})).To(Succeed()) - - for _, s := range []struct{ id, typ, ids string }{ - {"art-share", "artist", secondaryID}, - {"art-album-share", "album", "art-album-ok,art-album-other"}, - {"art-mf-share", "media_file", "art-ok,art-other"}, - } { - _, err := b.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": s.id, "user": owner.ID, "desc": "Scope share", - "type": s.typ, "ids": s.ids, "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 IN ('art-share', 'art-album-share', 'art-mf-share')`).Execute() - mr := NewMediaFileRepository(b).(*mediaFileRepository) - _, _ = mr.executeSQL(adminCtx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}})) - alr := NewAlbumRepository(b).(*albumRepository) - _, _ = alr.executeSQL(adminCtx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"art-album-ok", "art-album-other"}})) - ar := NewArtistRepository(b).(*artistRepository) - _, _ = ar.executeSQL(adminCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": []string{primaryID, secondaryID}})) - lr := NewLibraryRepository(b).(*libraryRepository) - _ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID}) - _ = NewUserRepository(b).Delete(adminCtx, owner.ID) - }) - - It("includes co-album-artist tracks the owner can access and excludes those they cannot", func() { - // Read as admin (mimics the public-share render path); loadMedia must still - // scope to the owner's libraries. - adminCtx := request.WithUser(ctx, adminUser) - adminRepo := NewShareRepository(GetDBXBuilder()) - share, err := adminRepo.Get(adminCtx, "art-share") - Expect(err).ToNot(HaveOccurred()) - - Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")), - "a co-album-artist track (not matched by album_artist_id) must be included") - Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")), - "a track outside the owner's libraries must not appear in the share") - - Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok")), - "a co-album-artist album must be included") - Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")), - "an album outside the owner's libraries must not appear in the share") - }) - - It("excludes albums and their tracks outside the owner's libraries from an album share", func() { - // Public share rendering has no user in the context. - share, err := NewShareRepository(GetDBXBuilder()).Get(log.NewContext(GinkgoT().Context()), "art-album-share") - Expect(err).ToNot(HaveOccurred()) - Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok"))) - Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other"))) - Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok"))) - Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other"))) - }) - - It("excludes tracks outside the owner's libraries from a media file share", func() { - share, err := NewShareRepository(GetDBXBuilder()).Get(log.NewContext(GinkgoT().Context()), "art-mf-share") - Expect(err).ToNot(HaveOccurred()) - Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok"))) - Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other"))) - }) - }) - Describe("Ownership Checks", func() { var ownerUser = model.User{ID: "2222", UserName: "regular-user"} var otherUser = model.User{ID: "3333", UserName: "third-user"} @@ -365,267 +155,67 @@ 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(GinkgoT().Context()), ownerUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Delete(ctx, "own-share-del") + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Delete("own-share-del") Expect(err).ToNot(HaveOccurred()) }) It("denies a non-admin user from deleting another user's share", func() { insertShare("other-share-del", ownerUser.ID) - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Delete(ctx, "other-share-del") + ctx := request.WithUser(log.NewContext(context.TODO()), 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(GetDBXBuilder()) - _, err = ownerRepo.Read(ownerCtx, "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(GinkgoT().Context()), adminUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Delete(ctx, "admin-del-share") + ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Delete("admin-del-share") Expect(err).ToNot(HaveOccurred()) }) It("allows headless context (no user) to delete a share", func() { insertShare("headless-del-share", ownerUser.ID) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Delete(GinkgoT().Context(), "headless-del-share") + repo := NewShareRepository(context.Background(), GetDBXBuilder()) + err := repo.(rest.Persistable).Delete("headless-del-share") Expect(err).ToNot(HaveOccurred()) }) }) - Describe("Save", func() { - It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { - ur := NewUserRepository(GetDBXBuilder()) - Expect(ur.Put(ctx, &ownerUser)).To(Succeed()) - Expect(ur.Put(ctx, &otherUser)).To(Succeed()) - - attackerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) - attackerRepo := NewShareRepository(GetDBXBuilder()) - - id, err := attackerRepo.Save(attackerCtx, &model.Share{ - ID: "spoof-save-share", UserID: otherUser.ID, - ResourceType: "media_file", ResourceIDs: "1001", - }) - Expect(err).ToNot(HaveOccurred()) - - adminCtx := request.WithUser(ctx, adminUser) - adminRepo := NewShareRepository(GetDBXBuilder()) - got, err := adminRepo.Get(adminCtx, id) - Expect(err).ToNot(HaveOccurred()) - Expect(got.UserID).To(Equal(ownerUser.ID)) - }) - }) - 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(GinkgoT().Context()), ownerUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Update(ctx, "own-share-upd", model.Share{Description: "Updated"}, "description") + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description") Expect(err).ToNot(HaveOccurred()) }) It("denies a non-admin user from updating another user's share", func() { insertShare("other-share-upd", ownerUser.ID) - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Update(ctx, "other-share-upd", model.Share{Description: "Hacked"}, "description") + ctx := request.WithUser(log.NewContext(context.TODO()), otherUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description") Expect(err).To(Equal(rest.ErrPermissionDenied)) }) It("allows an admin to update any user's share", func() { insertShare("admin-upd-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Update(ctx, "admin-upd-share", model.Share{Description: "Admin Updated"}, "description") + ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description") Expect(err).ToNot(HaveOccurred()) }) It("allows headless context (no user) to update a share", func() { insertShare("headless-upd-share", ownerUser.ID) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Update(GinkgoT().Context(), "headless-upd-share", model.Share{Description: "Headless"}, "description") + repo := NewShareRepository(context.Background(), 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(GetDBXBuilder()) - err := repo.Update(ctx, "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(GetDBXBuilder()) - // No cols: the update must write every column, not just updated_at. - err := repo.Update(ctx, "all-cols-share", - model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"}) - Expect(err).ToNot(HaveOccurred()) - - share, err := repo.Read(ctx, "all-cols-share") - Expect(err).ToNot(HaveOccurred()) - 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(GetDBXBuilder()) - err := repo.Update(ctx, "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.Read(ctx, "reassign-share") - Expect(err).ToNot(HaveOccurred()) - Expect(got.UserID).To(Equal(ownerUser.ID)) - }) - }) - - Describe("Read scoping", func() { - BeforeEach(func() { - // Persist owner/other users so the JOIN in selectShare resolves. - ur := NewUserRepository(GetDBXBuilder()) - Expect(ur.Put(ctx, &ownerUser)).To(Succeed()) - Expect(ur.Put(ctx, &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 nonAdminCtx context.Context - - BeforeEach(func() { - nonAdminCtx = request.WithUser(ctx, ownerUser) - nonAdminRepo = NewShareRepository(GetDBXBuilder()) - }) - - It("GetAll returns only own shares", func() { - shares, err := nonAdminRepo.GetAll(nonAdminCtx) - 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() { - shares, err := nonAdminRepo.ReadAll(nonAdminCtx) - 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("Get returns own share", func() { - s, err := nonAdminRepo.Get(nonAdminCtx, "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(nonAdminCtx, "share-other-1") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("Read returns ErrNotFound for another user's share", func() { - _, err := nonAdminRepo.Read(nonAdminCtx, "share-other-1") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("Exists returns true for own share", func() { - exists, err := nonAdminRepo.Exists(nonAdminCtx, "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(nonAdminCtx, "share-other-1") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - }) - - It("CountAll counts only own shares", func() { - count, err := nonAdminRepo.CountAll(nonAdminCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeNumerically("==", 2)) - }) - - It("Count (rest) counts only own shares", func() { - count, err := nonAdminRepo.Count(nonAdminCtx) - 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(GetDBXBuilder()) - shares, err := adminRepo.GetAll(adminCtx) - 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(GetDBXBuilder()) - count, err := adminRepo.CountAll(adminCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeNumerically("==", 3)) - }) - }) - - Context("headless context (public share route)", func() { - var headlessCtx context.Context - - BeforeEach(func() { - headlessCtx = GinkgoT().Context() - }) - - It("GetAll returns all shares", func() { - headlessRepo := NewShareRepository(GetDBXBuilder()) - shares, err := headlessRepo.GetAll(headlessCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(shares).To(HaveLen(3)) - }) - - It("Get returns another user's share", func() { - headlessRepo := NewShareRepository(GetDBXBuilder()) - s, err := headlessRepo.Get(headlessCtx, "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(GetDBXBuilder()) - exists, err := headlessRepo.Exists(headlessCtx, "share-other-1") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - }) - }) }) }) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 871f53dd2..54f316152 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -1,15 +1,12 @@ package persistence import ( - "context" - "slices" "time" . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "golang.org/x/text/unicode/norm" ) // PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated @@ -19,84 +16,72 @@ import ( // configured refresh delay. // refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly. -func (r *playlistRepository) refreshSmartPlaylist(ctx context.Context, pls *model.Playlist) bool { - return r.refreshSmartPlaylistTree(ctx, pls, map[string]struct{}{}) -} - -// The visited set stops playlists that reference each other from recursing forever. -func (r *playlistRepository) refreshSmartPlaylistTree(ctx context.Context, pls *model.Playlist, visited map[string]struct{}) bool { - if _, seen := visited[pls.ID]; seen { - log.Trace(ctx, "Skipping already visited smart playlist", "playlist", pls.Name, "id", pls.ID) - return false - } - visited[pls.ID] = struct{}{} - - usr := loggedUser(ctx) - if !r.shouldRefreshSmartPlaylist(ctx, pls, usr) { +func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { + usr := loggedUser(r.ctx) + if !r.shouldRefreshSmartPlaylist(pls, usr) { return false } - log.Debug(ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) + log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) start := time.Now() del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID}) - if _, err := r.executeSQL(ctx, del); err != nil { - log.Error(ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + if _, err := r.executeSQL(del); err != nil { + log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) return false } - rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr)) + rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr)) - if !r.refreshChildPlaylists(ctx, pls, rulesSQL, visited) { + if !r.refreshChildPlaylists(pls, rulesSQL) { return false } - if err := r.resolvePercentageLimit(ctx, pls, &rulesSQL, usr.ID); err != nil { + if err := r.resolvePercentageLimit(pls, &rulesSQL, usr.ID); err != nil { return false } - sq := r.buildSmartPlaylistQuery(ctx, pls, rulesSQL, usr.ID) + sq := r.buildSmartPlaylistQuery(pls, rulesSQL, usr.ID) sq, err := r.addCriteria(sq, rulesSQL) if err != nil { - log.Error(ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) return false } insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) - if _, err = r.executeSQL(ctx, insSql); err != nil { - log.Error(ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + if _, err = r.executeSQL(insSql); err != nil { + log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) return false } - if err = r.refreshCounters(ctx, pls); err != nil { - log.Error(ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) + if err = r.refreshCounters(pls); err != nil { + log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) return false } - // Reuse the stamp refreshCounters just wrote, so evaluated_at and updated_at agree - now := pls.UpdatedAt + now := time.Now() updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) - if _, err = r.executeSQL(ctx, updSql); err != nil { - log.Error(ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) + if _, err = r.executeSQL(updSql); err != nil { + log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) return false } pls.EvaluatedAt = &now - log.Debug(ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) + log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) return true } // shouldRefreshSmartPlaylist determines if a smart playlist needs to be refreshed based on its type, last evaluated // time, and ownership. -func (r *playlistRepository) shouldRefreshSmartPlaylist(ctx context.Context, pls *model.Playlist, usr *model.User) bool { +func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr *model.User) bool { if !pls.IsSmartPlaylist() { return false } - if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < pls.RefreshDelay() { + if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay { return false } if pls.OwnerID != usr.ID { - log.Trace(ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) + log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) return false } return true @@ -104,93 +89,106 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(ctx context.Context, pls // refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria. // Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort. -func (r *playlistRepository) refreshChildPlaylists(ctx context.Context, pls *model.Playlist, rulesSQL smartPlaylistCriteria, visited map[string]struct{}) bool { +func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool { childPlaylistIds := rulesSQL.ChildPlaylistIds() - childPlaylistPaths := rulesSQL.ChildPlaylistPaths() - if len(childPlaylistIds) == 0 && len(childPlaylistPaths) == 0 { + if len(childPlaylistIds) == 0 { return true } - var conditions Or - if len(childPlaylistIds) > 0 { - conditions = append(conditions, Eq{"playlist.id": childPlaylistIds}) - } - if len(childPlaylistPaths) > 0 { - lookupPaths := slices.Concat(slice.Map(childPlaylistPaths, pathVariants)...) - conditions = append(conditions, Eq{"playlist.path": lookupPaths}) - } - - childPlaylists, err := r.GetAll(ctx, model.QueryOptions{Filters: conditions}) + childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}}) if err != nil { - log.Error(ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, "childPaths", childPlaylistPaths, err) + log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err) return false } - found := make(map[string]struct{}, len(childPlaylists)*2) + found := make(map[string]struct{}, len(childPlaylists)) for i := range childPlaylists { found[childPlaylists[i].ID] = struct{}{} - if childPlaylists[i].Path != "" { - found[norm.NFC.String(childPlaylists[i].Path)] = struct{}{} - } - r.refreshSmartPlaylistTree(ctx, &childPlaylists[i], visited) + r.refreshSmartPlaylist(&childPlaylists[i]) } for _, id := range childPlaylistIds { if _, ok := found[id]; !ok { - log.Warn(ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) - } - } - - for _, path := range childPlaylistPaths { - if _, ok := found[norm.NFC.String(path)]; !ok { - log.Warn(ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "path", path, "ownerId", pls.OwnerID) + log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) } } return true } // resolvePercentageLimit calculates the actual limit for a smart playlist criteria that uses a percentage-based limit. -func (r *playlistRepository) resolvePercentageLimit(ctx context.Context, pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error { +func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error { if !rulesSQL.IsPercentageLimit() { return nil } + exprJoins := rulesSQL.ExpressionJoins() countSq := Select("count(*) as count").From("media_file") - countSq = rulesSQL.applyExpressionJoins(countSq, userID) - countSq = r.applyLibraryFilter(ctx, countSq, "media_file") + countSq = r.addMediaFileAnnotationJoin(countSq, userID) + countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID) + countSq = r.applyLibraryFilter(countSq, "media_file") - cond, err := rulesSQL.where() + cond, err := rulesSQL.Where() if err != nil { - log.Error(ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) return err } countSq = countSq.Where(cond) var res struct{ Count int64 } - if err = r.queryOne(ctx, countSq, &res); err != nil { - log.Error(ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) + if err = r.queryOne(countSq, &res); err != nil { + log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) return err } rulesSQL.ResolveLimit(res.Count) - log.Debug(ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit) + log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit) return nil } // buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria, -// including the joins its fields require and library filtering. -func (r *playlistRepository) buildSmartPlaylistQuery(ctx context.Context, pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { - orderBy := rulesSQL.orderBy() +// including necessary joins for annotations and library filtering. +func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { + orderBy := rulesSQL.OrderBy() sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). From("media_file") - sq = rulesSQL.applyRequiredJoins(sq, userID) - sq = r.applyLibraryFilter(ctx, sq, "media_file") + sq = r.addMediaFileAnnotationJoin(sq, userID) + + requiredJoins := rulesSQL.RequiredJoins() + sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID) + sq = r.applyLibraryFilter(sq, "media_file") + return sq +} + +// addMediaFileAnnotationJoin adds a left join to the annotation table for media files, filtering by user ID to include +// user-specific annotations in the smart playlist criteria evaluation. +func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder { + return sq.LeftJoin("annotation on ("+ + "annotation.item_id = media_file.id"+ + " AND annotation.item_type = 'media_file'"+ + " AND annotation.user_id = ?)", userID) +} + +// addSmartPlaylistAnnotationJoins adds left joins to the annotation table for albums and artists as needed based on +// the smart playlist criteria, filtering by user ID to include user-specific annotations in the evaluation. +func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { + if joins.has(smartPlaylistJoinAlbumAnnotation) { + sq = sq.LeftJoin("annotation AS album_annotation ON ("+ + "album_annotation.item_id = media_file.album_id"+ + " AND album_annotation.item_type = 'album'"+ + " AND album_annotation.user_id = ?)", userID) + } + if joins.has(smartPlaylistJoinArtistAnnotation) { + sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ + "artist_annotation.item_id = media_file.artist_id"+ + " AND artist_annotation.item_type = 'artist'"+ + " AND artist_annotation.user_id = ?)", userID) + } return sq } // addCriteria applies the where conditions, limit, offset, and order by clauses to the SQL query based on the // smart playlist criteria. func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { - cond, err := cSQL.where() + cond, err := cSQL.Where() if err != nil { return sql, err } @@ -198,7 +196,7 @@ func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCr if cSQL.Criteria.Limit > 0 { sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset)) } - if order := cSQL.orderBy(); order != "" { + if order := cSQL.OrderBy(); order != "" { sql = sql.OrderBy(order) } return sql, nil diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 4d4c5edb0..7bc705385 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -1,8 +1,6 @@ package persistence import ( - "context" - "path/filepath" "time" "github.com/navidrome/navidrome/conf" @@ -11,7 +9,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -19,11 +16,11 @@ import ( var _ = Describe("PlaylistRepository - Smart Playlists", func() { var repo model.PlaylistRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(GetDBXBuilder()) + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()) }) Context("Smart Playlists", func() { @@ -38,32 +35,15 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Context("valid rules", func() { Specify("Put/Get", func() { newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) - savedPls, err := repo.Get(ctx, newPls.ID) + savedPls, err := repo.Get(newPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(savedPls.Rules).To(Equal(rules)) }) }) - Context("after an evaluation", func() { - It("stamps updated_at and evaluated_at with the same instant", func() { - newPls := model.Playlist{Name: "Evaluated", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - - refreshed, err := repo.GetWithTracks(ctx, newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - stored, err := repo.Get(ctx, newPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(stored.EvaluatedAt).ToNot(BeNil()) - Expect(stored.UpdatedAt).To(BeTemporally("==", *stored.EvaluatedAt)) - Expect(refreshed.UpdatedAt).To(BeTemporally("==", stored.UpdatedAt)) - }) - }) - Context("invalid rules", func() { It("fails to Put it in the DB", func() { rules = &criteria.Criteria{ @@ -73,42 +53,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) - }) - }) - - Context("re-imported from disk", func() { - // The scanner re-imports every playlist in a touched folder, and a freshly parsed - // .nsp carries no counters — saving it must not wipe the ones already evaluated. - It("keeps the stored counters when a freshly parsed playlist is saved over it", func() { - rules = &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "Antenna"}, - }, - } - pls := model.Playlist{Name: "Smart", OwnerID: "userid", Rules: rules, Path: "/music/smart.nsp", Sync: true} - Expect(repo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) }) - - evaluated, err := repo.GetWithTracks(ctx, pls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - Expect(evaluated.SongCount).To(BeNumerically(">", 0)) - - stored, err := repo.Get(ctx, pls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(stored.SongCount).To(Equal(evaluated.SongCount)) - - reimported := model.Playlist{ - ID: pls.ID, Name: pls.Name, OwnerID: "userid", Rules: rules, - Path: pls.Path, Sync: true, - } - Expect(repo.Put(ctx, &reimported)).To(Succeed()) - - afterImport, err := repo.Get(ctx, pls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(afterImport.SongCount).To(Equal(stored.SongCount)) - Expect(afterImport.Duration).To(Equal(stored.Duration)) - Expect(afterImport.Size).To(Equal(stored.Size)) + Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) }) }) @@ -126,122 +71,41 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { criteria.Contains{"title": "Day"}, }, } - nestedPls := model.Playlist{Name: "Nested [ID]", OwnerID: "userid", Public: true, Rules: childRules} - Expect(repo.Put(ctx, &nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, nestedPls.ID) }) - - childRules = &criteria.Criteria{ - Expression: criteria.All{ - criteria.Eq{"artist": "シートベルツ"}, - }, - } - nestedPathPls := model.Playlist{Name: "Nested [Path]", OwnerID: "userid", Path: "test.nsp", Public: true, Rules: childRules} - Expect(repo.Put(ctx, &nestedPathPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, nestedPathPls.ID) }) + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} + Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.Any{ + Expression: criteria.All{ criteria.InPlaylist{"id": nestedPls.ID}, - criteria.InPlaylist{"path": nestedPathPls.Path}, }, }} - Expect(repo.Put(ctx, &parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, parentPls.ID) }) + Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) // Nested playlist has not been evaluated yet - nestedPlsRead, err := repo.Get(ctx, nestedPls.ID) + nestedPlsRead, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) // Getting parent with refresh should recursively refresh the nested playlist - pls, err := repo.GetWithTracks(ctx, parentPls.ID, true, false) + pls, err := repo.GetWithTracks(parentPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) Expect(pls.EvaluatedAt).ToNot(BeNil()) Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) // Parent should have tracks from the nested playlist - Expect(pls.Tracks).To(HaveLen(2)) + Expect(pls.Tracks).To(HaveLen(1)) Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - // Nested playlists should now have been refreshed (EvaluatedAt set) - nestedPlsAfterParentGet, err := repo.Get(ctx, nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - - nestedPlsAfterParentGet, err = repo.Get(ctx, nestedPathPls.ID) + // Nested playlist should now have been refreshed (EvaluatedAt set) + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) }) }) - It("does not recurse forever when two smart playlists reference each other", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - - plsA := model.Playlist{Name: "Cycle A", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "Day"}}, - }} - Expect(repo.Put(ctx, &plsA)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, plsA.ID) }) - - plsB := model.Playlist{Name: "Cycle B", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.InPlaylist{"id": plsA.ID}}, - }} - Expect(repo.Put(ctx, &plsB)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, plsB.ID) }) - - plsA.Rules = &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"id": plsB.ID}}} - Expect(repo.Put(ctx, &plsA)).To(Succeed()) - - _, err := repo.GetWithTracks(ctx, plsA.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - }) - - It("does not treat an empty path as a reference to every playlist without a path", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - - bystander := model.Playlist{Name: "Bystander", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "Day"}}, - }} - Expect(repo.Put(ctx, &bystander)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, bystander.ID) }) - - parent := model.Playlist{Name: "Empty Path", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.InPlaylist{"path": ""}}, - }} - Expect(repo.Put(ctx, &parent)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, parent.ID) }) - - _, err := repo.GetWithTracks(ctx, parent.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - reloaded, err := repo.Get(ctx, bystander.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(reloaded.EvaluatedAt).To(BeNil()) - }) - - It("matches a child path stored in a different Unicode normalization form", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - - child := model.Playlist{Name: "NFD Child", OwnerID: "userid", Public: true, Path: filepath.FromSlash("/mu\u0301sica/child.nsp"), Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "Day"}}, - }} - Expect(repo.Put(ctx, &child)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, child.ID) }) - - parent := model.Playlist{Name: "NFC Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.InPlaylist{"path": "/m\u00fasica/child.nsp"}}, - }} - Expect(repo.Put(ctx, &parent)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, parent.ID) }) - - pls, err := repo.GetWithTracks(ctx, parent.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - }) - When("refresh delay has not expired", func() { It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour @@ -253,8 +117,8 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} - Expect(repo.Put(ctx, &nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, nestedPls.ID) }) + Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) // Parent has no EvaluatedAt, so it WILL refresh, but the child should not parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ @@ -262,14 +126,14 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { criteria.InPlaylist{"id": nestedPls.ID}, }, }} - Expect(repo.Put(ctx, &parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, parentPls.ID) }) + Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) - nestedPlsRead, err := repo.Get(ctx, nestedPls.ID) + nestedPlsRead, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) // Getting parent with refresh should NOT recursively refresh the nested playlist - parent, err := repo.GetWithTracks(ctx, parentPls.ID, true, false) + parent, err := repo.GetWithTracks(parentPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) // Parent should have been refreshed (its EvaluatedAt was nil) @@ -277,56 +141,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) // Nested playlist should NOT have been refreshed (still within delay window) - nestedPlsAfterParentGet, err := repo.Get(ctx, nestedPls.ID) + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) Expect(err).ToNot(HaveOccurred()) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) }) }) - - Context("per-playlist refreshDelay", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("does NOT refresh when the per-playlist delay has not elapsed, even if global has", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - evaluatedAt := time.Now().Add(-1 * time.Hour) - - rules := &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "Day"}}, - RefreshDelay: 24 * time.Hour, - } - pls := model.Playlist{Name: "Frozen Daily", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} - Expect(repo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) }) - - got, err := repo.GetWithTracks(ctx, pls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - // Not re-evaluated: EvaluatedAt unchanged, no tracks materialized - Expect(*got.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) - Expect(got.Tracks).To(BeEmpty()) - }) - - It("refreshes when the per-playlist delay has elapsed, even if global has not", func() { - conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour - evaluatedAt := time.Now().Add(-10 * time.Minute) - - rules := &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "Day"}}, - RefreshDelay: 5 * time.Minute, - } - pls := model.Playlist{Name: "Fast Refresh", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} - Expect(repo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) }) - - got, err := repo.GetWithTracks(ctx, pls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - Expect(*got.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - Expect(got.Tracks).To(HaveLen(1)) - Expect(got.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - }) - }) }) }) @@ -335,7 +155,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { AfterEach(func() { if testPlaylistID != "" { - Expect(repo.Delete(ctx, testPlaylistID)).To(BeNil()) + Expect(repo.Delete(testPlaylistID)).To(BeNil()) testPlaylistID = "" } }) @@ -345,12 +165,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} // Add tracks in intentionally scrambled order newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) - Expect(repo.Put(ctx, &newPls)).To(Succeed()) + Expect(repo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID By("retrieving tracks sorted by album") - tracksRepo := repo.Tracks(ctx, newPls.ID, false) - tracks, err := tracksRepo.GetAll(ctx, model.QueryOptions{Sort: "album", Order: "asc"}) + tracksRepo := repo.Tracks(newPls.ID, false) + tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) Expect(err).ToNot(HaveOccurred()) By("verifying tracks are sorted by disc number then track number") @@ -368,7 +188,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { AfterEach(func() { if testPlaylistID != "" { - _ = repo.Delete(ctx, testPlaylistID) + _ = repo.Delete(testPlaylistID) testPlaylistID = "" } }) @@ -382,11 +202,11 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) + Expect(repo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) + pls, err := repo.GetWithTracks(newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) trackIDs := make([]string, len(pls.Tracks)) @@ -405,11 +225,11 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) + Expect(repo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) + pls, err := repo.GetWithTracks(newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) trackIDs := make([]string, len(pls.Tracks)) @@ -430,11 +250,11 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) + Expect(repo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) + pls, err := repo.GetWithTracks(newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) trackIDs := make([]string, len(pls.Tracks)) @@ -452,11 +272,11 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) + Expect(repo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) + pls, err := repo.GetWithTracks(newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(BeEmpty()) @@ -472,11 +292,11 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "String Loved Nested", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) + Expect(repo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) + pls, err := repo.GetWithTracks(newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) trackIDs := make([]string, len(pls.Tracks)) @@ -496,8 +316,8 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } boolPls := model.Playlist{Name: "Bool Loved", OwnerID: "userid", Rules: boolRules} - Expect(repo.Put(ctx, &boolPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, boolPls.ID) }) + Expect(repo.Put(&boolPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(boolPls.ID) }) stringRules := &criteria.Criteria{ Expression: criteria.All{ @@ -507,13 +327,13 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } stringPls := model.Playlist{Name: "String Loved", OwnerID: "userid", Rules: stringRules} - Expect(repo.Put(ctx, &stringPls)).To(Succeed()) + Expect(repo.Put(&stringPls)).To(Succeed()) testPlaylistID = stringPls.ID conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - boolResult, err := repo.GetWithTracks(ctx, boolPls.ID, true, false) + boolResult, err := repo.GetWithTracks(boolPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) - stringResult, err := repo.GetWithTracks(ctx, stringPls.ID, true, false) + stringResult, err := repo.GetWithTracks(stringPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) boolIDs := make([]string, len(boolResult.Tracks)) @@ -528,40 +348,6 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }) }) - Describe("Smart Playlists with Album Aggregate Criteria", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - }) - - trackIDsOf := func(rules *criteria.Criteria) []string { - newPls := model.Playlist{Name: "Album Aggregates", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - - pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - return slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID }) - } - - It("filters on albumSongCount", func() { - // albumMultiDisc (ID "104") is the only fixture album with SongCount > 3 - rules := &criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumSongCount": 3}}} - - Expect(trackIDsOf(rules)).To(ConsistOf("2001", "2002", "2003", "2004")) - }) - - It("sorts by an album field not referenced in the expression (issue #5347)", func() { - // All four tracks share album 104, so the album date ties and disc/track number decide. - rules := &criteria.Criteria{ - Expression: criteria.All{criteria.Is{"album": "Multi Disc Album"}}, - Sort: "-albumDateAdded,discNumber,trackNumber", - } - - Expect(trackIDsOf(rules)).To(HaveExactElements("2002", "2004", "2003", "2001")) - }) - }) - Describe("Smart Playlists with Tag Criteria", func() { var mfRepo model.MediaFileRepository var testPlaylistID string @@ -570,7 +356,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { BeforeEach(func() { ctx := log.NewContext(GinkgoT().Context()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(GetDBXBuilder()) + mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) // Register 'grouping' as a valid tag for smart playlists criteria.AddTagNames([]string{"grouping"}) @@ -591,7 +377,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { LibraryID: 1, Lyrics: "[]", } - Expect(mfRepo.Put(ctx, &songWithGrouping)).To(Succeed()) + Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) // Create a song without the grouping tag songWithoutGrouping = model.MediaFile{ @@ -607,12 +393,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { LibraryID: 1, Lyrics: "[]", } - Expect(mfRepo.Put(ctx, &songWithoutGrouping)).To(Succeed()) + Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) }) AfterEach(func() { if testPlaylistID != "" { - _ = repo.Delete(ctx, testPlaylistID) + _ = repo.Delete(testPlaylistID) testPlaylistID = "" } // Clean up test media files @@ -630,12 +416,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) + Expect(repo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID By("refreshing the smart playlist") conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) + pls, err := repo.GetWithTracks(newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) By("verifying only the track with grouping tag is matched") @@ -651,12 +437,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) + Expect(repo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID By("refreshing the smart playlist") conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) + pls, err := repo.GetWithTracks(newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) By("verifying the track with grouping is NOT in the playlist") @@ -706,7 +492,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { // Create test media files in each library ctx := log.NewContext(GinkgoT().Context()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(db) + mfRepo = NewMediaFileRepository(ctx, db) // Song in library 1 (accessible by restricted user) songLib1 := model.MediaFile{ @@ -722,7 +508,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Tags: model.Tags{}, Lyrics: "[]", } - Expect(mfRepo.Put(ctx, &songLib1)).To(Succeed()) + Expect(mfRepo.Put(&songLib1)).To(Succeed()) // Song in library 2 (NOT accessible by restricted user) songLib2 := model.MediaFile{ @@ -738,13 +524,13 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Tags: model.Tags{}, Lyrics: "[]", } - Expect(mfRepo.Put(ctx, &songLib2)).To(Succeed()) + Expect(mfRepo.Put(&songLib2)).To(Succeed()) }) AfterEach(func() { db := GetDBXBuilder() if testPlaylistID != "" { - _ = repo.Delete(ctx, testPlaylistID) + _ = repo.Delete(testPlaylistID) testPlaylistID = "" } // Clean up test data @@ -762,7 +548,7 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { // Create the smart playlist as the restricted user restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false} ctx = request.WithUser(ctx, restrictedUser) - restrictedRepo := NewPlaylistRepository(db) + restrictedRepo := NewPlaylistRepository(ctx, db) // Create a smart playlist that matches all songs rules := &criteria.Criteria{ @@ -771,12 +557,12 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }, } newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules} - Expect(restrictedRepo.Put(ctx, &newPls)).To(Succeed()) + Expect(restrictedRepo.Put(&newPls)).To(Succeed()) testPlaylistID = newPls.ID By("refreshing the smart playlist") conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := restrictedRepo.GetWithTracks(ctx, newPls.ID, true, false) + pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false) Expect(err).ToNot(HaveOccurred()) By("verifying only the track from library 1 is in the playlist") diff --git a/persistence/sort_index_coverage_test.go b/persistence/sort_index_coverage_test.go deleted file mode 100644 index 138cb32c8..000000000 --- a/persistence/sort_index_coverage_test.go +++ /dev/null @@ -1,168 +0,0 @@ -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(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(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(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 51c7dc146..07bd96975 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -1,75 +1,29 @@ package persistence import ( - "context" "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(ctx context.Context, query SelectBuilder, idField string) SelectBuilder { - userID := loggedUser(ctx).ID +func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder { + userID := loggedUser(r.ctx).ID if userID == invalidUserId { return query.Columns(fmt.Sprintf("%s.average_rating", r.tableName)) } query = query. LeftJoin("annotation on ("+ "annotation.item_id = "+idField+ - " AND annotation.item_type = ?"+ - " AND annotation.user_id = ?)", r.tableName, userID). + " AND annotation.user_id = '"+userID+"')"). Columns( "coalesce(starred, 0) as starred", "coalesce(rating, 0) as rating", @@ -103,8 +57,8 @@ func annotationBoolFilter(field string) func(string, any) Sqlizer { } } -func (r sqlRepository) annId(ctx context.Context, itemID ...string) And { - userID := loggedUser(ctx).ID +func (r sqlRepository) annId(itemID ...string) And { + userID := loggedUser(r.ctx).ID return And{ Eq{annotationTable + ".user_id": userID}, Eq{annotationTable + ".item_type": r.tableName}, @@ -112,20 +66,20 @@ func (r sqlRepository) annId(ctx context.Context, itemID ...string) And { } } -func (r sqlRepository) annUpsert(ctx context.Context, values map[string]any, itemIDs ...string) error { - upd := Update(annotationTable).Where(r.annId(ctx, itemIDs...)) +func (r sqlRepository) annUpsert(values map[string]any, itemIDs ...string) error { + upd := Update(annotationTable).Where(r.annId(itemIDs...)) for f, v := range values { upd = upd.Set(f, v) } - c, err := r.executeSQL(ctx, upd) + c, err := r.executeSQL(upd) if c == 0 || errors.Is(err, sql.ErrNoRows) { - userID := loggedUser(ctx).ID + userID := loggedUser(r.ctx).ID for _, itemID := range itemIDs { values["user_id"] = userID values["item_type"] = r.tableName values["item_id"] = itemID ins := Insert(annotationTable).SetMap(values) - _, err = r.executeSQL(ctx, ins) + _, err = r.executeSQL(ins) if err != nil { return err } @@ -134,39 +88,39 @@ func (r sqlRepository) annUpsert(ctx context.Context, values map[string]any, ite return err } -func (r sqlRepository) SetStar(ctx context.Context, starred bool, ids ...string) error { +func (r sqlRepository) SetStar(starred bool, ids ...string) error { starredAt := time.Now() - return r.annUpsert(ctx, map[string]any{"starred": starred, "starred_at": starredAt}, ids...) + return r.annUpsert(map[string]any{"starred": starred, "starred_at": starredAt}, ids...) } -func (r sqlRepository) SetRating(ctx context.Context, rating int, itemID string) error { +func (r sqlRepository) SetRating(rating int, itemID string) error { ratedAt := time.Now() - err := r.annUpsert(ctx, map[string]any{"rating": rating, "rated_at": ratedAt}, itemID) + err := r.annUpsert(map[string]any{"rating": rating, "rated_at": ratedAt}, itemID) if err != nil { return err } - return r.updateAvgRating(ctx, itemID) + return r.updateAvgRating(itemID) } -func (r sqlRepository) updateAvgRating(ctx context.Context, itemID string) error { +func (r sqlRepository) updateAvgRating(itemID string) error { upd := Update(r.tableName). Where(Eq{"id": itemID}). Set("average_rating", Expr( "coalesce((select round(avg(rating), 2) from annotation where item_id = ? and item_type = ? and rating > 0), 0)", itemID, r.tableName, )) - _, err := r.executeSQL(ctx, upd) + _, err := r.executeSQL(upd) return err } -func (r sqlRepository) IncPlayCount(ctx context.Context, itemID string, ts time.Time) error { - upd := Update(annotationTable).Where(r.annId(ctx, itemID)). +func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error { + upd := Update(annotationTable).Where(r.annId(itemID)). Set("play_count", Expr("play_count+1")). Set("play_date", Expr("max(ifnull(play_date,''),?)", ts)) - c, err := r.executeSQL(ctx, upd) + c, err := r.executeSQL(upd) if c == 0 || errors.Is(err, sql.ErrNoRows) { - userID := loggedUser(ctx).ID + userID := loggedUser(r.ctx).ID values := map[string]any{} values["user_id"] = userID values["item_type"] = r.tableName @@ -174,7 +128,7 @@ func (r sqlRepository) IncPlayCount(ctx context.Context, itemID string, ts time. values["play_count"] = 1 values["play_date"] = ts ins := Insert(annotationTable).SetMap(values) - _, err = r.executeSQL(ctx, ins) + _, err = r.executeSQL(ins) if err != nil { return err } @@ -182,28 +136,26 @@ func (r sqlRepository) IncPlayCount(ctx context.Context, itemID string, ts time. return err } -func (r sqlRepository) ReassignAnnotation(ctx context.Context, prevID string, newID string) error { +func (r sqlRepository) ReassignAnnotation(prevID string, newID string) error { if prevID == newID || prevID == "" || newID == "" { return nil } - // OR IGNORE keeps newID's own row where a user annotated both, instead of aborting the whole statement - upd := Expr("update or ignore "+annotationTable+" set item_id = ? where item_type = ? and item_id = ?", - newID, r.tableName, prevID) - if _, err := r.executeSQL(ctx, upd); err != nil { - return err - } - // The moved rows change newID's rating population, so its cached average no longer matches - return r.updateAvgRating(ctx, newID) + upd := Update(annotationTable).Where(And{ + Eq{annotationTable + ".item_type": r.tableName}, + Eq{annotationTable + ".item_id": prevID}, + }).Set("item_id", newID) + _, err := r.executeSQL(upd) + return err } -func (r sqlRepository) cleanAnnotations(ctx context.Context) error { +func (r sqlRepository) cleanAnnotations() error { del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") - c, err := r.executeSQL(ctx, del) + c, err := r.executeSQL(del) if err != nil { return fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err) } if c > 0 { - log.Debug(ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c) + log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c) } return nil } diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go index 6761331d9..15efc5dc7 100644 --- a/persistence/sql_annotations_test.go +++ b/persistence/sql_annotations_test.go @@ -15,67 +15,19 @@ var _ = Describe("Annotation Filters", func() { var ( albumRepo *albumRepository albumWithoutAnnotation model.Album - ctx context.Context ) BeforeEach(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) - albumRepo = NewAlbumRepository(GetDBXBuilder()).(*albumRepository) + ctx := request.WithUser(context.Background(), model.User{ID: "userid", UserName: "johndoe"}) + albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) // Create album without any annotation (no star, no rating) albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1} - Expect(albumRepo.Put(ctx, &albumWithoutAnnotation)).To(Succeed()) + Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed()) }) AfterEach(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) - }) - - Describe("ReassignAnnotation", func() { - var prev, next model.Album - - BeforeEach(func() { - prev = model.Album{ID: "reassign-prev", Name: "Prev", LibraryID: 1} - next = model.Album{ID: "reassign-next", Name: "Next", LibraryID: 1} - Expect(albumRepo.Put(ctx, &prev)).To(Succeed()) - Expect(albumRepo.Put(ctx, &next)).To(Succeed()) - }) - - AfterEach(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{prev.ID, next.ID}})) - }) - - It("moves the annotation when the new item has none", func() { - Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed()) - - Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := albumRepo.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Rating).To(Equal(4)) - }) - - It("recomputes the new item's cached average rating", func() { - Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed()) - - Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := albumRepo.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.AverageRating).To(Equal(4.0)) - }) - - It("keeps the new item's annotation when both exist", func() { - Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed()) - Expect(albumRepo.SetRating(ctx, 2, next.ID)).To(Succeed()) - - Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := albumRepo.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Rating).To(Equal(2)) - }) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) }) Describe("annotationBoolFilter", func() { @@ -91,8 +43,6 @@ var _ = Describe("Annotation Filters", func() { Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)), Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)), Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)), - Entry("play_count=true", "play_count", "true", "COALESCE(play_count, 0) > 0", []any(nil)), - Entry("play_count=false", "play_count", "false", "COALESCE(play_count, 0) = 0", []any(nil)), ) It("returns nil if value is not a string", func() { @@ -103,7 +53,7 @@ var _ = Describe("Annotation Filters", func() { Describe("starredFilter", func() { It("false includes items without annotations", func() { - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ + albums, err := albumRepo.GetAll(model.QueryOptions{ Filters: annotationBoolFilter("starred")("starred", "false"), }) Expect(err).ToNot(HaveOccurred()) @@ -119,7 +69,7 @@ var _ = Describe("Annotation Filters", func() { }) It("true excludes items without annotations", func() { - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ + albums, err := albumRepo.GetAll(model.QueryOptions{ Filters: annotationBoolFilter("starred")("starred", "true"), }) Expect(err).ToNot(HaveOccurred()) @@ -132,7 +82,7 @@ var _ = Describe("Annotation Filters", func() { Describe("hasRatingFilter", func() { It("false includes items without annotations", func() { - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ + albums, err := albumRepo.GetAll(model.QueryOptions{ Filters: annotationBoolFilter("rating")("rating", "false"), }) Expect(err).ToNot(HaveOccurred()) @@ -148,7 +98,7 @@ var _ = Describe("Annotation Filters", func() { }) It("true excludes items without annotations", func() { - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ + albums, err := albumRepo.GetAll(model.QueryOptions{ Filters: annotationBoolFilter("rating")("rating", "true"), }) Expect(err).ToNot(HaveOccurred()) @@ -161,14 +111,14 @@ var _ = Describe("Annotation Filters", func() { It("true includes items with rating > 0", func() { // Create album with rating 1 ratedAlbum := model.Album{ID: "rated-album", Name: "Rated Album", LibraryID: 1} - Expect(albumRepo.Put(ctx, &ratedAlbum)).To(Succeed()) - Expect(albumRepo.SetRating(ctx, 1, ratedAlbum.ID)).To(Succeed()) + Expect(albumRepo.Put(&ratedAlbum)).To(Succeed()) + Expect(albumRepo.SetRating(1, ratedAlbum.ID)).To(Succeed()) defer func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": ratedAlbum.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": ratedAlbum.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": ratedAlbum.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ratedAlbum.ID})) }() - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ + albums, err := albumRepo.GetAll(model.QueryOptions{ Filters: annotationBoolFilter("rating")("rating", "true"), }) Expect(err).ToNot(HaveOccurred()) @@ -185,11 +135,11 @@ var _ = Describe("Annotation Filters", func() { }) It("ignores invalid filter values (not strings)", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ + res, err := albumRepo.ReadAll(rest.QueryOptions{ Filters: map[string]any{"starred": 123}, }) Expect(err).ToNot(HaveOccurred()) - albums := res + albums := res.(model.Albums) var found bool for _, a := range albums { @@ -200,98 +150,4 @@ 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(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(total).To(BeNumerically(">=", int64(1))) - - filtered, err := albumRepo.CountAll(ctx, 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(ctx, &starredAlbum)).To(Succeed()) - Expect(albumRepo.SetStar(ctx, true, starredAlbum.ID)).To(Succeed()) - defer func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": starredAlbum.ID})) - _, _ = albumRepo.executeSQL(ctx, 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(ctx, 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(ctx, 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 03cc6a01b..fd263d37b 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "crypto/md5" "database/sql" "errors" "fmt" @@ -12,9 +13,7 @@ import ( "time" . "github.com/Masterminds/squirrel" - "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" id2 "github.com/navidrome/navidrome/model/id" @@ -22,14 +21,13 @@ import ( "github.com/navidrome/navidrome/utils/hasher" "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" - "github.com/zeebo/xxh3" ) // sqlRepository is the base repository for all SQL repositories. It provides common functions to interact with the DB. // When creating a new repository using this base, you must: // // - Embed this struct. -// - Set the db field. +// - Set ctx and db fields. ctx should be the context passed to the constructor method, usually obtained from the request // - Call registerModel with the model instance and any possible filters. // - If the model has a different table name than the default (lowercase of the model name), it should be set manually // using the tableName field. @@ -38,6 +36,7 @@ import ( // All fields in filters and sortMappings must be in snake_case. Only sorts and filters based on real field names or // defined in the mappings will be allowed. type sqlRepository struct { + ctx context.Context tableName string db dbx.Builder @@ -58,49 +57,6 @@ 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(ctx context.Context) Sqlizer { - if usr := loggedUser(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(ctx context.Context, sql ...Sqlizer) Sqlizer { - s := And{} - if len(sql) > 0 { - s = append(s, sql[0]) - } - if owner := r.ownerFilter(ctx); owner != nil { - s = append(s, owner) - } - return s -} - -// writeAccess says who may change a row in a table with a user_id column. -type writeAccess int - -const ( - ownerOrAdmin writeAccess = iota // admins may write any row - ownerOnly // even admins may only write their own rows -) - -// ownedRow matches the row rowID only if the logged-in user may write it under access. -func (r sqlRepository) ownedRow(ctx context.Context, rowID string, access writeAccess) Sqlizer { - if access == ownerOnly { - return And{Eq{"id": rowID}, Eq{"user_id": loggedUser(ctx).ID}} - } - return r.addRestriction(ctx, Eq{"id": rowID}) -} - func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { if r.tableName == "" { r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") @@ -112,8 +68,6 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun } // setSortMappings sets the mappings for the sort fields. If the sort field is not in the map, it will be used as is. -// This applies per comma-separated part, so a key added here also defines that bare name wherever a -// caller uses it inside a sort list. // // If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression, // which gives precedence to sort tags. @@ -128,18 +82,19 @@ func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName .. if len(tableName) > 0 { tn = tableName[0] } - if conf.Server.PreferSortTags || conf.Server.EnableNaturalSorting { + if conf.Server.PreferSortTags { for k, v := range mappings { - mappings[k] = mapSortOrder(tn, v) + v = mapSortOrder(tn, v) + mappings[k] = v } } r.sortMappings = mappings } -func (r sqlRepository) newSelect(ctx context.Context, options ...model.QueryOptions) SelectBuilder { +func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder { sq := Select().From(r.tableName) if len(options) > 0 { - r.resetSeededRandom(ctx, options) + r.resetSeededRandom(options) sq = r.applyOptions(sq, options...) sq = r.applyFilters(sq, options...) } @@ -163,37 +118,17 @@ func (r sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOpti // TODO Change all sortMappings to have a consistent case func (r sqlRepository) sortMapping(sort string) string { - if mapping, _, ok := r.lookupSortMapping(sort); ok { + if mapping, ok := r.sortMappings[sort]; ok { return mapping } - // Each part of a comma list is resolved on its own, so a mix of mapped keys and plain columns - // keeps the mappings the recognized parts have. - parts := strings.FieldsFunc(sort, splitFunc(',')) - mapped := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if partMapping, _, ok := r.lookupSortMapping(part); ok { - part = partMapping - } else { - part = toSnakeCase(part) - } - mapped = append(mapped, part) + if mapping, ok := r.sortMappings[toCamelCase(sort)]; ok { + return mapping } - return strings.Join(mapped, ", ") -} - -// lookupSortMapping also returns the snake_case form when it had to derive one, so a caller's -// fallback doesn't recompute it: toSnakeCase runs two regexps. -func (r sqlRepository) lookupSortMapping(sort string) (mapping, snakeCased string, ok bool) { - if mapping, ok = r.sortMappings[sort]; ok { - return mapping, sort, true + sort = toSnakeCase(sort) + if mapping, ok := r.sortMappings[sort]; ok { + return mapping } - if mapping, ok = r.sortMappings[toCamelCase(sort)]; ok { - return mapping, "", true - } - snakeCased = toSnakeCase(sort) - mapping, ok = r.sortMappings[snakeCased] - return mapping, snakeCased, ok + return sort } func (r sqlRepository) buildSortOrder(sort, order string) string { @@ -251,6 +186,15 @@ 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} @@ -258,20 +202,14 @@ func libraryIdFilter(_ string, value any) Sqlizer { // applyLibraryFilter adds library filtering to queries for tables that have a library_id column // This ensures users only see content from libraries they have access to -func (r sqlRepository) applyLibraryFilter(ctx context.Context, sq SelectBuilder, tableName ...string) SelectBuilder { - user := loggedUser(ctx) +func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) SelectBuilder { + user := loggedUser(r.ctx) // If the user is an admin, or the user ID is invalid (e.g., when no user is logged in), skip the library filter if user.IsAdmin || user.ID == invalidUserId { 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(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) { - return sq - } - table := r.tableName if len(tableName) > 0 { table = tableName[0] @@ -283,68 +221,39 @@ func (r sqlRepository) applyLibraryFilter(ctx context.Context, sq SelectBuilder, "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(ctx context.Context, visible []int) bool { - user := loggedUser(ctx) - if user.IsAdmin || user.ID == invalidUserId { - return true // visible is the whole library table - } - var res struct{ Count int64 } - err := r.queryOne(ctx, Select("count(*) as count").From("library"), &res) - if err != nil || res.Count == 0 { - return false - } - return int64(len(visible)) == res.Count -} - -// 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(ctx context.Context) ([]int, error) { - user := loggedUser(ctx) - if user.IsAdmin || user.ID == invalidUserId { - var ids []int - err := r.queryAllSlice(ctx, 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(ctx context.Context) string { +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 - userIDHash := xxh3.Hash([]byte(loggedUser(ctx).ID)) - return fmt.Sprintf("%s|%016x", r.tableName, userIDHash) + userIDHash := md5.Sum([]byte(loggedUser(r.ctx).ID)) + return fmt.Sprintf("%s|%x", r.tableName, userIDHash) } -func (r sqlRepository) resetSeededRandom(ctx context.Context, options []model.QueryOptions) { +func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) { if len(options) == 0 || options[0].Sort != "random" { return } - // CAST: playlist_tracks.id is an INTEGER (unlike other tables' TEXT ids); passing it to - // SEEDEDRAND's string param uncast silently drops every row (go-sqlite3 binding gotcha). - options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', CAST(%s.id AS TEXT))", r.seedKey(ctx), r.tableName) + options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', %s.id)", r.seedKey(), r.tableName) if options[0].Seed != "" { - hasher.SetSeed(r.seedKey(ctx), options[0].Seed) + hasher.SetSeed(r.seedKey(), options[0].Seed) return } if options[0].Offset == 0 { - hasher.Reseed(r.seedKey(ctx)) + hasher.Reseed(r.seedKey()) } } -func (r sqlRepository) executeSQL(ctx context.Context, sq Sqlizer) (int64, error) { +func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) { query, args, err := r.toSQL(sq) if err != nil { return 0, err } start := time.Now() var c int64 - res, err := r.db.NewQuery(query).Bind(args).WithContext(ctx).Execute() + res, err := r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Execute() if res != nil { c, _ = res.RowsAffected() } - r.logSQL(ctx, query, args, err, c, start) + r.logSQL(query, args, err, c, start) if err != nil { if err.Error() != "LastInsertId is not supported by this driver" { return 0, err @@ -372,43 +281,24 @@ func (r sqlRepository) toSQL(sq Sqlizer) (string, dbx.Params, error) { return result, params, nil } -func (r sqlRepository) queryOne(ctx context.Context, sq Sqlizer, response any) error { +func (r sqlRepository) queryOne(sq Sqlizer, response any) error { query, args, err := r.toSQL(sq) if err != nil { return err } start := time.Now() - err = r.db.NewQuery(query).Bind(args).WithContext(ctx).One(response) + err = r.db.NewQuery(query).Bind(args).WithContext(r.ctx).One(response) if errors.Is(err, sql.ErrNoRows) { - r.logSQL(ctx, query, args, nil, 0, start) + r.logSQL(query, args, nil, 0, start) return model.ErrNotFound } - r.logSQL(ctx, query, args, err, 1, start) + r.logSQL(query, args, err, 1, start) return err } -// wrapCursor adapts a cursor over db rows into one over their models. toModel pulls out the row's -// embedded model, which a type parameter can't reach on its own. -func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.Seq2[T, error] { - return func(yield func(T, error) bool) { - for row, err := range cursor { - m := toModel(row) - if m == nil { - // Don't format row: its String() derefs the nil model (golang/go#81238). - var zero T - yield(zero, fmt.Errorf("unexpected nil %T: %w", zero, err)) - return - } - if !yield(*m, err) || err != nil { - return - } - } - } -} - // queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results // from a cursor, guaranteeing that the results will be stable, even if the underlying data changes. -func queryWithStableResults[T any](ctx context.Context, r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) { +func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) { if len(options) > 0 && options[0].Offset > 0 { sq = r.optimizePagination(sq, options[0]) } @@ -417,8 +307,8 @@ func queryWithStableResults[T any](ctx context.Context, r sqlRepository, sq Sele return nil, err } start := time.Now() - rows, err := r.db.NewQuery(query).Bind(args).WithContext(ctx).Rows() - r.logSQL(ctx, query, args, err, -1, start) + rows, err := r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Rows() + r.logSQL(query, args, err, -1, start) if err != nil { return nil, err } @@ -438,7 +328,7 @@ func queryWithStableResults[T any](ctx context.Context, r sqlRepository, sq Sele }, nil } -func (r sqlRepository) queryAll(ctx context.Context, sq SelectBuilder, response any, options ...model.QueryOptions) error { +func (r sqlRepository) queryAll(sq SelectBuilder, response any, options ...model.QueryOptions) error { if len(options) > 0 && options[0].Offset > 0 { sq = r.optimizePagination(sq, options[0]) } @@ -447,28 +337,28 @@ func (r sqlRepository) queryAll(ctx context.Context, sq SelectBuilder, response return err } start := time.Now() - err = r.db.NewQuery(query).Bind(args).WithContext(ctx).All(response) + err = r.db.NewQuery(query).Bind(args).WithContext(r.ctx).All(response) if errors.Is(err, sql.ErrNoRows) { - r.logSQL(ctx, query, args, nil, -1, start) + r.logSQL(query, args, nil, -1, start) return model.ErrNotFound } - r.logSQL(ctx, query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start) + r.logSQL(query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start) return err } // queryAllSlice is a helper function to query a single column and return the result in a slice -func (r sqlRepository) queryAllSlice(ctx context.Context, sq SelectBuilder, response any) error { +func (r sqlRepository) queryAllSlice(sq SelectBuilder, response any) error { query, args, err := r.toSQL(sq) if err != nil { return err } start := time.Now() - err = r.db.NewQuery(query).Bind(args).WithContext(ctx).Column(response) + err = r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Column(response) if errors.Is(err, sql.ErrNoRows) { - r.logSQL(ctx, query, args, nil, -1, start) + r.logSQL(query, args, nil, -1, start) return model.ErrNotFound } - r.logSQL(ctx, query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start) + r.logSQL(query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start) return err } @@ -485,84 +375,14 @@ func (r sqlRepository) optimizePagination(sq SelectBuilder, options model.QueryO return sq } -func (r sqlRepository) exists(ctx context.Context, cond Sqlizer) (bool, error) { +func (r sqlRepository) exists(cond Sqlizer) (bool, error) { existsQuery := Select("count(*) as exist").From(r.tableName).Where(cond) var res struct{ Exist int64 } - err := r.queryOne(ctx, existsQuery, &res) + err := r.queryOne(existsQuery, &res) 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(ctx context.Context, 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 - return r.updateOwnedRow(ctx, id, ownerOrAdmin, updateValues) -} - -// updateOwnedRow sets values on the row rowID if the logged-in user may write it under access. -func (r sqlRepository) updateOwnedRow(ctx context.Context, rowID string, access writeAccess, values map[string]any) error { - return r.runRowWrite(ctx, rowID, Update(r.tableName).SetMap(values).Where(r.ownedRow(ctx, rowID, access))) -} - -// 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(ctx context.Context, id string) error { - return r.runRowWrite(ctx, id, Delete(r.tableName).Where(r.ownedRow(ctx, id, ownerOrAdmin))) -} - -// runRowWrite executes q, a write already filtered by ownedRow(rowID, …), and classifies a miss. -func (r sqlRepository) runRowWrite(ctx context.Context, rowID string, q Sqlizer) error { - count, err := r.executeSQL(ctx, q) - if err != nil { - return err - } - if count == 0 { - return r.classifyOwnedWriteMiss(ctx, rowID) - } - return nil -} - -func (r sqlRepository) deleteOwnedAll(ctx context.Context, ids ...string) error { - for _, id := range ids { - if err := r.deleteOwned(ctx, id); err != nil { - return err - } - } - 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(ctx context.Context, id string) error { - exists, err := r.exists(ctx, Eq{"id": id}) - if err != nil { - return err - } - if exists { - return rest.ErrPermissionDenied - } - return rest.ErrNotFound -} - -func (r sqlRepository) count(ctx context.Context, countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { +func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { countQuery = countQuery. RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count"). RemoveOffset().RemoveLimit(). @@ -570,60 +390,50 @@ func (r sqlRepository) count(ctx context.Context, countQuery SelectBuilder, opti From(r.tableName) countQuery = r.applyFilters(countQuery, options...) var res struct{ Count int64 } - err := r.queryOne(ctx, countQuery, &res) + err := r.queryOne(countQuery, &res) return res.Count, err } -func (r sqlRepository) putByMatch(ctx context.Context, filter Sqlizer, id string, m any, colsToUpdate ...string) (string, error) { +func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate ...string) (string, error) { if id != "" { - return r.put(ctx, id, m, colsToUpdate...) + return r.put(id, m, colsToUpdate...) } - existsQuery := r.newSelect(ctx).Columns("id").From(r.tableName).Where(filter) + existsQuery := r.newSelect().Columns("id").From(r.tableName).Where(filter) var res struct{ ID string } - err := r.queryOne(ctx, existsQuery, &res) + err := r.queryOne(existsQuery, &res) if err != nil && !errors.Is(err, model.ErrNotFound) { return "", err } - return r.put(ctx, res.ID, m, colsToUpdate...) + return r.put(res.ID, m, colsToUpdate...) } -// selectUpdateColumns keeps 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 selectUpdateColumns(values map[string]any, colsToUpdate ...string) map[string]any { - updateValues := map[string]any{} - - 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 - } - } - - 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 filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { - updateValues := selectUpdateColumns(values, colsToUpdate...) - updateValues["id"] = id - return updateValues -} - -func (r sqlRepository) put(ctx context.Context, id string, m any, colsToUpdate ...string) (newId string, err error) { +func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) { values, err := toSQLArgs(m) if err != nil { return "", fmt.Errorf("error preparing values to write to DB: %w", err) } // If there's an ID, try to update first if id != "" { - update := Update(r.tableName).Where(Eq{"id": id}).SetMap(filterUpdateValues(values, id, colsToUpdate...)) - count, err := r.executeSQL(ctx, update) + 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) + count, err := r.executeSQL(update) if err != nil { return "", err } @@ -637,42 +447,24 @@ func (r sqlRepository) put(ctx context.Context, id string, m any, colsToUpdate . values["id"] = id } insert := Insert(r.tableName).SetMap(values) - _, err = r.executeSQL(ctx, insert) + _, err = r.executeSQL(insert) return id, err } -func (r sqlRepository) delete(ctx context.Context, cond Sqlizer) error { - _, err := r.executeSQL(ctx, Delete(r.tableName).Where(cond)) +func (r sqlRepository) delete(cond Sqlizer) error { + del := Delete(r.tableName).Where(cond) + _, err := r.executeSQL(del) + if errors.Is(err, sql.ErrNoRows) { + return model.ErrNotFound + } return err } -// deleteByID is for single-item deletes that must report a missing row; delete succeeds silently. -func (r sqlRepository) deleteByID(ctx context.Context, id string) error { - count, err := r.executeSQL(ctx, Delete(r.tableName).Where(Eq{"id": id})) - if err != nil { - return err - } - if count == 0 { - return model.ErrNotFound - } - return nil -} - -func (r sqlRepository) logSQL(ctx context.Context, sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { +func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { elapsed := time.Since(start) - fields := []any{ctx, "SQL: `" + sql + "`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed} if err == nil || errors.Is(err, context.Canceled) { - log.Trace(append(fields, err)...) - return + log.Trace(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err) + } else { + log.Error(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err) } - // The result codes separate errors that share a message, notably SQLITE_BUSY from - // SQLITE_BUSY_SNAPSHOT, which no busy_timeout can retry. - if code, extended, ok := db.ErrorCodes(err); ok { - fields = append(fields, "sqliteCode", code, "sqliteExtended", extended) - } - if db.IsBusy(err) && hasBusyRetry(ctx) { - log.Warn(append(fields, err)...) - return - } - log.Error(append(fields, err)...) } diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index 4b42e7f1d..b46e2066b 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -13,32 +13,11 @@ import ( var _ = Describe("sqlRepository", func() { var r sqlRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "user-id"}) + r.ctx = request.WithUser(context.Background(), model.User{ID: "user-id"}) r.tableName = "table" }) - Describe("ownedRow", func() { - DescribeTable("matches the row, limited to what the logged-in user may write", - func(user model.User, access writeAccess, expectedSQL string, expectedArgs ...any) { - userCtx := request.WithUser(GinkgoT().Context(), user) - sql, args, err := r.ownedRow(userCtx, "row-1", access).ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(expectedSQL)) - Expect(args).To(Equal(expectedArgs)) - }, - Entry("admin, ownerOrAdmin: any row", model.User{ID: "admin", IsAdmin: true}, ownerOrAdmin, - "(id = ?)", "row-1"), - Entry("regular, ownerOrAdmin: own rows", model.User{ID: "user"}, ownerOrAdmin, - "(id = ? AND user_id = ?)", "row-1", "user"), - Entry("admin, ownerOnly: own rows", model.User{ID: "admin", IsAdmin: true}, ownerOnly, - "(id = ? AND user_id = ?)", "row-1", "admin"), - Entry("regular, ownerOnly: own rows", model.User{ID: "user"}, ownerOnly, - "(id = ? AND user_id = ?)", "row-1", "user"), - ) - }) - Describe("applyOptions", func() { var sq squirrel.SelectBuilder BeforeEach(func() { @@ -109,106 +88,53 @@ var _ = Describe("sqlRepository", func() { When("sanitizing sort", func() { It("returns empty if the sort key is not found in the model nor in the mappings", func() { - sort, _ := r.sanitizeSort(ctx, "unknown", "") + sort, _ := r.sanitizeSort("unknown", "") Expect(sort).To(BeEmpty()) }) - // Validation only: buildSortOrder resolves the mapping, so mapping here too would hand - // sortMapping its own output and re-map values whose parts are themselves keys. - It("accepts a known sort key without resolving it", func() { - sort, _ := r.sanitizeSort(ctx, "sort1", "") - Expect(sort).To(Equal("sort1")) + It("returns the mapped value when sort key exists", func() { + sort, _ := r.sanitizeSort("sort1", "") + Expect(sort).To(Equal("mappedSort1")) }) It("is case insensitive", func() { - sort, _ := r.sanitizeSort(ctx, "Sort1", "") - Expect(sort).To(Equal("sort1")) - }) - - It("still resolves the mapping by the time the SQL is built", func() { - Expect(r.buildSortOrder("sort1", "asc")).To(Equal("mappedSort1 asc")) - }) - - // A mapping whose parts are themselves keys (media_file rated_at = "rating, rated_at") - // must survive the round trip through sanitizeSort and buildSortOrder unduplicated. - It("does not re-map a value whose parts are also keys", func() { - r.sortMappings = map[string]string{"rating": "rating", "rated_at": "rating, rated_at"} - sort, _ := r.sanitizeSort(ctx, "rated_at", "") - Expect(r.buildSortOrder(sort, "asc")).To(Equal("rating asc, rated_at asc")) + sort, _ := r.sanitizeSort("Sort1", "") + Expect(sort).To(Equal("mappedSort1")) }) It("returns the field if it is a valid field", func() { - sort, _ := r.sanitizeSort(ctx, "field", "") + sort, _ := r.sanitizeSort("field", "") Expect(sort).To(Equal("field")) }) It("is case insensitive for fields", func() { - sort, _ := r.sanitizeSort(ctx, "FIELD", "") + sort, _ := r.sanitizeSort("FIELD", "") Expect(sort).To(Equal("field")) }) }) When("sanitizing order", func() { It("returns 'asc' if order is empty", func() { - _, order := r.sanitizeSort(ctx, "", "") + _, order := r.sanitizeSort("", "") Expect(order).To(Equal("")) }) It("returns 'asc' if order is 'asc'", func() { - _, order := r.sanitizeSort(ctx, "", "ASC") + _, order := r.sanitizeSort("", "ASC") Expect(order).To(Equal("asc")) }) It("returns 'desc' if order is 'desc'", func() { - _, order := r.sanitizeSort(ctx, "", "desc") + _, order := r.sanitizeSort("", "desc") Expect(order).To(Equal("desc")) }) It("returns 'asc' if order is unknown", func() { - _, order := r.sanitizeSort(ctx, "", "something") + _, order := r.sanitizeSort("", "something") Expect(order).To(Equal("asc")) }) }) }) - Describe("sortMapping", func() { - BeforeEach(func() { - r.sortMappings = map[string]string{ - "name": "order_album_name, order_album_artist_name", - "recently_added": "album.created_at, album.id", - } - }) - It("maps a single key", func() { - Expect(r.sortMapping("recently_added")).To(Equal("album.created_at, album.id")) - }) - It("maps every part of a comma list when all of them are known keys", func() { - Expect(r.sortMapping("recently_added, name")). - To(Equal("album.created_at, album.id, order_album_name, order_album_artist_name")) - }) - It("resolves the known parts of a mixed list and leaves the rest as columns", func() { - Expect(r.sortMapping("recently_added, play_count")). - To(Equal("album.created_at, album.id, play_count")) - }) - // Jellyfin's MusicAlbum SortBy=Runtime,SortName arrives as "duration, name"; duration is a - // plain album column while name is mapped, and the mapping must survive the mix. - It("keeps a mapping when an earlier part is a plain column", func() { - Expect(r.sortMapping("duration, name")). - To(Equal("duration, order_album_name, order_album_artist_name")) - }) - It("leaves a raw column list with directions untouched", func() { - Expect(r.sortMapping("starred desc, rating desc")).To(Equal("starred desc, rating desc")) - }) - It("does not split an expression on a comma inside its parentheses", func() { - Expect(r.sortMapping("coalesce(name, ''), title")).To(Equal("coalesce(name, ''), title")) - Expect(r.sortMapping("coalesce(nullif(a,''), b) desc, c")).To(Equal("coalesce(nullif(a,''), b) desc, c")) - }) - It("keeps a mapping whose value nests commas inside parentheses", func() { - r.sortMappings["max_year"] = "coalesce(nullif(original_date,''), cast(max_year as text)), release_date" - Expect(r.sortMapping("max_year, name")).To(Equal( - "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + - "order_album_name, order_album_artist_name")) - }) - }) - Describe("buildSortOrder", func() { BeforeEach(func() { r.sortMappings = map[string]string{} @@ -269,153 +195,88 @@ var _ = Describe("sqlRepository", func() { Describe("resetSeededRandom", func() { var id string BeforeEach(func() { - id = r.seedKey(ctx) + id = r.seedKey() hasher.SetSeed(id, "") }) It("does not reset seed if sort is not random", func() { var options []model.QueryOptions - r.resetSeededRandom(ctx, options) + r.resetSeededRandom(options) Expect(hasher.CurrentSeed(id)).To(BeEmpty()) }) It("resets seed if sort is random", func() { options := []model.QueryOptions{{Sort: "random"}} - r.resetSeededRandom(ctx, options) + r.resetSeededRandom(options) Expect(hasher.CurrentSeed(id)).NotTo(BeEmpty()) }) It("resets seed if sort is random and seed is provided", func() { options := []model.QueryOptions{{Sort: "random", Seed: "seed"}} - r.resetSeededRandom(ctx, options) + r.resetSeededRandom(options) Expect(hasher.CurrentSeed(id)).To(Equal("seed")) }) It("keeps seed when paginating", func() { options := []model.QueryOptions{{Sort: "random", Seed: "seed", Offset: 0}} - r.resetSeededRandom(ctx, options) + r.resetSeededRandom(options) Expect(hasher.CurrentSeed(id)).To(Equal("seed")) options = []model.QueryOptions{{Sort: "random", Offset: 1}} - r.resetSeededRandom(ctx, options) + r.resetSeededRandom(options) Expect(hasher.CurrentSeed(id)).To(Equal("seed")) }) }) 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() { BeforeEach(func() { - ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true}) + r.ctx = request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true}) }) It("should not apply library filter for admin users", func() { - result := r.applyLibraryFilter(ctx, sq) - sql, _, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) + result := r.applyLibraryFilter(sq) + sql, _, _ := result.ToSql() Expect(sql).To(Equal("SELECT * FROM test_table")) }) }) - Context("Regular User with a subset of libraries", func() { + Context("Regular User", func() { BeforeEach(func() { - // Strict subset: granted lib 1, DB has libs 1 and 2, so the filter must apply. - ctx = request.WithUser(ctx, model.User{ - ID: "user123", IsAdmin: false, Libraries: model.Libraries{{ID: 1}}, - }) + r.ctx = request.WithUser(context.Background(), model.User{ID: "user123", IsAdmin: false}) }) It("should apply library filter for regular users", func() { - result := r.applyLibraryFilter(ctx, sq) - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) + result := r.applyLibraryFilter(sq) + sql, args, _ := result.ToSql() 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(ctx, sq, "custom_table") - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) + result := r.applyLibraryFilter(sq, "custom_table") + sql, args, _ := result.ToSql() Expect(sql).To(ContainSubstring("custom_table.library_id IN")) Expect(args).To(ContainElement("user123")) }) }) - Context("Regular User with no libraries", func() { - BeforeEach(func() { - ctx = request.WithUser(ctx, model.User{ID: "empty", IsAdmin: false}) - }) - - It("should apply the library filter (never skip on empty)", func() { - result := r.applyLibraryFilter(ctx, 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}) - } - ctx = request.WithUser(ctx, model.User{ - ID: "alllibs", IsAdmin: false, Libraries: libs, - }) - }) - - It("should not apply the library filter (subquery would filter nothing)", func() { - result := r.applyLibraryFilter(ctx, 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(ctx, 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() { - ctx = GinkgoT().Context() // No user context + r.ctx = context.Background() // No user context }) It("should not apply library filter for headless processes", func() { - result := r.applyLibraryFilter(ctx, sq) - sql, _, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) + result := r.applyLibraryFilter(sq) + sql, _, _ := result.ToSql() Expect(sql).To(Equal("SELECT * FROM test_table")) }) It("should not apply library filter even with custom table name", func() { - result := r.applyLibraryFilter(ctx, sq, "custom_table") - sql, _, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) + result := r.applyLibraryFilter(sq, "custom_table") + sql, _, _ := result.ToSql() Expect(sql).To(Equal("SELECT * FROM test_table")) }) }) diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index ddb63019d..19f16b231 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -1,7 +1,6 @@ package persistence import ( - "context" "database/sql" "errors" "fmt" @@ -15,8 +14,8 @@ import ( const bookmarkTable = "bookmark" -func (r sqlRepository) withBookmark(ctx context.Context, query SelectBuilder, idField string) SelectBuilder { - userID := loggedUser(ctx).ID +func (r sqlRepository) withBookmark(query SelectBuilder, idField string) SelectBuilder { + userID := loggedUser(r.ctx).ID if userID == invalidUserId { return query } @@ -27,17 +26,17 @@ func (r sqlRepository) withBookmark(ctx context.Context, query SelectBuilder, id Columns("coalesce(position, 0) as bookmark_position") } -func (r sqlRepository) bmkID(ctx context.Context, itemID ...string) And { +func (r sqlRepository) bmkID(itemID ...string) And { return And{ - Eq{bookmarkTable + ".user_id": loggedUser(ctx).ID}, + Eq{bookmarkTable + ".user_id": loggedUser(r.ctx).ID}, Eq{bookmarkTable + ".item_type": r.tableName}, Eq{bookmarkTable + ".item_id": itemID}, } } -func (r sqlRepository) bmkUpsert(ctx context.Context, itemID, comment string, position int64) error { - client, _ := request.ClientFrom(ctx) - user, _ := request.UserFrom(ctx) +func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error { + client, _ := request.ClientFrom(r.ctx) + user, _ := request.UserFrom(r.ctx) values := map[string]any{ "comment": comment, "position": position, @@ -45,10 +44,10 @@ func (r sqlRepository) bmkUpsert(ctx context.Context, itemID, comment string, po "changed_by": client, } - upd := Update(bookmarkTable).Where(r.bmkID(ctx, itemID)).SetMap(values) - c, err := r.executeSQL(ctx, upd) + upd := Update(bookmarkTable).Where(r.bmkID(itemID)).SetMap(values) + c, err := r.executeSQL(upd) if err == nil { - log.Debug(ctx, "Updated bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) + log.Debug(r.ctx, "Updated bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) } if c == 0 || errors.Is(err, sql.ErrNoRows) { values["user_id"] = user.ID @@ -57,31 +56,31 @@ func (r sqlRepository) bmkUpsert(ctx context.Context, itemID, comment string, po values["created_at"] = time.Now() values["updated_at"] = time.Now() ins := Insert(bookmarkTable).SetMap(values) - _, err = r.executeSQL(ctx, ins) + _, err = r.executeSQL(ins) if err != nil { return err } - log.Debug(ctx, "Added bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) + log.Debug(r.ctx, "Added bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) } return err } -func (r sqlRepository) AddBookmark(ctx context.Context, id, comment string, position int64) error { - user, _ := request.UserFrom(ctx) - err := r.bmkUpsert(ctx, id, comment, position) +func (r sqlRepository) AddBookmark(id, comment string, position int64) error { + user, _ := request.UserFrom(r.ctx) + err := r.bmkUpsert(id, comment, position) if err != nil { - log.Error(ctx, "Error adding bookmark", "id", id, "user", user.UserName, "position", position, "comment", comment) + log.Error(r.ctx, "Error adding bookmark", "id", id, "user", user.UserName, "position", position, "comment", comment) } return err } -func (r sqlRepository) DeleteBookmark(ctx context.Context, id string) error { - user, _ := request.UserFrom(ctx) - del := Delete(bookmarkTable).Where(r.bmkID(ctx, id)) - _, err := r.executeSQL(ctx, del) +func (r sqlRepository) DeleteBookmark(id string) error { + user, _ := request.UserFrom(r.ctx) + del := Delete(bookmarkTable).Where(r.bmkID(id)) + _, err := r.executeSQL(del) if err != nil { - log.Error(ctx, "Error removing bookmark", "id", id, "user", user.UserName) + log.Error(r.ctx, "Error removing bookmark", "id", id, "user", user.UserName) } return err } @@ -97,18 +96,17 @@ type bookmark struct { UpdatedAt time.Time `json:"updatedAt"` } -func (r sqlRepository) GetBookmarks(ctx context.Context) (model.Bookmarks, error) { - user, _ := request.UserFrom(ctx) +func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { + user, _ := request.UserFrom(r.ctx) idField := r.tableName + ".id" - sq := r.newSelect(ctx).Columns(r.tableName + ".*") - sq = r.withAnnotation(ctx, sq, idField) - sq = r.withBookmark(ctx, sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) - sq = r.applyLibraryFilter(ctx, sq) + sq := r.newSelect().Columns(r.tableName + ".*") + sq = r.withAnnotation(sq, idField) + sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) var mfs dbMediaFiles // TODO Decouple from media_file - err := r.queryAll(ctx, sq, &mfs) + err := r.queryAll(sq, &mfs) if err != nil { - log.Error(ctx, "Error getting mediafiles with bookmarks", "user", user.UserName, err) + log.Error(r.ctx, "Error getting mediafiles with bookmarks", "user", user.UserName, err) return nil, err } @@ -119,18 +117,18 @@ func (r sqlRepository) GetBookmarks(ctx context.Context) (model.Bookmarks, error mfMap[mf.ID] = i } - sq = Select("*").From(bookmarkTable).Where(r.bmkID(ctx, ids...)) + sq = Select("*").From(bookmarkTable).Where(r.bmkID(ids...)) var bmks []bookmark - err = r.queryAll(ctx, sq, &bmks) + err = r.queryAll(sq, &bmks) if err != nil { - log.Error(ctx, "Error getting bookmarks", "user", user.UserName, "ids", ids, err) + log.Error(r.ctx, "Error getting bookmarks", "user", user.UserName, "ids", ids, err) return nil, err } resp := make(model.Bookmarks, len(bmks)) for i, bmk := range bmks { if itemIdx, ok := mfMap[bmk.ItemID]; !ok { - log.Debug(ctx, "Invalid bookmark", "id", bmk.ItemID, "user", user.UserName) + log.Debug(r.ctx, "Invalid bookmark", "id", bmk.ItemID, "user", user.UserName) continue } else { resp[i] = model.Bookmark{ @@ -146,21 +144,14 @@ func (r sqlRepository) GetBookmarks(ctx context.Context) (model.Bookmarks, error return resp, nil } -func (r sqlRepository) reassignBookmark(ctx context.Context, prevID, newID string) error { - upd := Expr("update or ignore "+bookmarkTable+" set item_id = ? where item_type = ? and item_id = ?", - newID, r.tableName, prevID) - _, err := r.executeSQL(ctx, upd) - return err -} - -func (r sqlRepository) cleanBookmarks(ctx context.Context) error { +func (r sqlRepository) cleanBookmarks() error { del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") - c, err := r.executeSQL(ctx, del) + c, err := r.executeSQL(del) if err != nil { return fmt.Errorf("error cleaning up %s bookmarks: %w", r.tableName, err) } if c > 0 { - log.Debug(ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName) + log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName) } return nil } diff --git a/persistence/sql_bookmarks_test.go b/persistence/sql_bookmarks_test.go index b50a35d5d..712a928db 100644 --- a/persistence/sql_bookmarks_test.go +++ b/persistence/sql_bookmarks_test.go @@ -12,23 +12,23 @@ import ( var _ = Describe("sqlBookmarks", func() { var mr model.MediaFileRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid"}) - mr = NewMediaFileRepository(GetDBXBuilder()) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + mr = NewMediaFileRepository(ctx, GetDBXBuilder()) }) Describe("Bookmarks", func() { It("returns an empty collection if there are no bookmarks", func() { - Expect(mr.GetBookmarks(ctx)).To(BeEmpty()) + Expect(mr.GetBookmarks()).To(BeEmpty()) }) It("saves and overrides bookmarks", func() { By("Saving the bookmark") - Expect(mr.AddBookmark(ctx, songAntenna.ID, "this is a comment", 123)).To(BeNil()) + Expect(mr.AddBookmark(songAntenna.ID, "this is a comment", 123)).To(BeNil()) - bms, err := mr.GetBookmarks(ctx) + bms, err := mr.GetBookmarks() Expect(err).ToNot(HaveOccurred()) Expect(bms).To(HaveLen(1)) @@ -42,9 +42,9 @@ var _ = Describe("sqlBookmarks", func() { Expect(updated).To(BeTemporally(">=", created)) By("Overriding the bookmark") - Expect(mr.AddBookmark(ctx, songAntenna.ID, "another comment", 333)).To(BeNil()) + Expect(mr.AddBookmark(songAntenna.ID, "another comment", 333)).To(BeNil()) - bms, err = mr.GetBookmarks(ctx) + bms, err = mr.GetBookmarks() Expect(err).ToNot(HaveOccurred()) Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) @@ -54,69 +54,21 @@ var _ = Describe("sqlBookmarks", func() { Expect(bms[0].UpdatedAt).To(BeTemporally(">=", updated)) By("Saving another bookmark") - Expect(mr.AddBookmark(ctx, songComeTogether.ID, "one more comment", 444)).To(BeNil()) - bms, err = mr.GetBookmarks(ctx) + Expect(mr.AddBookmark(songComeTogether.ID, "one more comment", 444)).To(BeNil()) + bms, err = mr.GetBookmarks() Expect(err).ToNot(HaveOccurred()) Expect(bms).To(HaveLen(2)) By("Delete bookmark") - Expect(mr.DeleteBookmark(ctx, songAntenna.ID)).To(Succeed()) - bms, err = mr.GetBookmarks(ctx) + Expect(mr.DeleteBookmark(songAntenna.ID)).To(Succeed()) + bms, err = mr.GetBookmarks() Expect(err).ToNot(HaveOccurred()) Expect(bms).To(HaveLen(1)) Expect(bms[0].Item.ID).To(Equal(songComeTogether.ID)) Expect(bms[0].Item.Title).To(Equal(songComeTogether.Title)) - Expect(mr.DeleteBookmark(ctx, songComeTogether.ID)).To(Succeed()) - Expect(mr.GetBookmarks(ctx)).To(BeEmpty()) - }) - }) - - Describe("library access", func() { - var otherLib model.Library - var restrictedUser model.User - var adminCtx, userCtx context.Context - var userMr model.MediaFileRepository - - BeforeEach(func() { - adminCtx, otherLib, restrictedUser = restrictedFixture("bmk") - - adminMr := NewMediaFileRepository(GetDBXBuilder()) - Expect(adminMr.Put(adminCtx, &model.MediaFile{ - ID: "bmk-otherlib-track", LibraryID: otherLib.ID, - Path: "hidden/bookmarked.mp3", Title: "Hidden Bookmarked", - })).To(Succeed()) - DeferCleanup(func() { _ = adminMr.Delete(adminCtx, "bmk-otherlib-track") }) - - userCtx = request.WithUser(ctx, restrictedUser) - userMr = NewMediaFileRepository(GetDBXBuilder()) - }) - - It("does not return bookmarks for tracks outside the user's libraries", func() { - Expect(userMr.AddBookmark(userCtx, "bmk-otherlib-track", "sneaky", 1)).To(Succeed()) - - Expect(userMr.GetBookmarks(userCtx)).To(BeEmpty()) - }) - - It("still returns the bookmark for an admin", func() { - adminMr := NewMediaFileRepository(GetDBXBuilder()) - Expect(adminMr.AddBookmark(adminCtx, "bmk-otherlib-track", "mine", 1)).To(Succeed()) - DeferCleanup(func() { _ = adminMr.DeleteBookmark(adminCtx, "bmk-otherlib-track") }) - - bms, err := adminMr.GetBookmarks(adminCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(bms).To(HaveLen(1)) - Expect(bms[0].Item.ID).To(Equal("bmk-otherlib-track")) - }) - - It("keeps returning bookmarks for tracks inside the user's libraries", func() { - Expect(userMr.AddBookmark(userCtx, songAntenna.ID, "allowed", 5)).To(Succeed()) - DeferCleanup(func() { _ = userMr.DeleteBookmark(userCtx, songAntenna.ID) }) - - bms, err := userMr.GetBookmarks(userCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(bms).To(HaveLen(1)) - Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) + Expect(mr.DeleteBookmark(songComeTogether.ID)).To(Succeed()) + Expect(mr.GetBookmarks()).To(BeEmpty()) }) }) }) diff --git a/persistence/sql_participations.go b/persistence/sql_participations.go index bc2af39e2..38b0203fa 100644 --- a/persistence/sql_participations.go +++ b/persistence/sql_participations.go @@ -1,7 +1,6 @@ package persistence import ( - "context" "encoding/json" "fmt" @@ -23,30 +22,6 @@ type flatParticipant struct { SubRole string `json:"sub_role,omitempty"` } -// ParticipantIDFilter matches rows of table where the artist participates in any of the given roles -// (any role when empty). Semi-joins _artists; json_tree over the JSON is far slower at scale. -func ParticipantIDFilter(table string, artistID any, roles ...model.Role) Sqlizer { - return participantIDFilter(table, artistID, false, roles) -} - -// NotParticipantIDFilter is the negation of ParticipantIDFilter. -func NotParticipantIDFilter(table string, artistID any, roles ...model.Role) Sqlizer { - return participantIDFilter(table, artistID, true, roles) -} - -func participantIDFilter(table string, artistID any, negate bool, roles []model.Role) Sqlizer { - sel := Select(table + "_id").From(table + "_artists").Where(Eq{"artist_id": artistID}) - if len(roles) > 0 { - sel = sel.Where(Eq{"role": slice.Map(roles, func(r model.Role) string { return r.String() })}) - } - sql, args, _ := sel.ToSql() - op := " IN (" - if negate { - op = " NOT IN (" - } - return Expr(table+".id"+op+sql+")", args...) -} - func marshalParticipants(participants model.Participants) string { dbParticipants := make(map[model.Role][]participant) for role, artists := range participants { @@ -75,12 +50,12 @@ func unmarshalParticipants(data string) (model.Participants, error) { return participants, nil } -func (r sqlRepository) updateParticipants(ctx context.Context, itemID string, participants model.Participants) error { +func (r sqlRepository) updateParticipants(itemID string, participants model.Participants) error { // Delete all existing participant entries for this item. // This ensures stale role associations are removed when an artist's role changes // (e.g., an artist was both albumartist and composer, but is now only composer). sqd := Delete(r.tableName + "_artists").Where(Eq{r.tableName + "_id": itemID}) - _, err := r.executeSQL(ctx, sqd) + _, err := r.executeSQL(sqd) if err != nil { return err } @@ -120,14 +95,14 @@ func (r sqlRepository) updateParticipants(ctx context.Context, itemID string, pa ON CONFLICT (artist_id, %[1]s_id, role, sub_role) DO NOTHING -- Ignore duplicates `, r.tableName) - _, err = r.executeSQL(ctx, Expr(query, itemID, string(participantsJSON))) + _, err = r.executeSQL(Expr(query, itemID, string(participantsJSON))) return err } -func (r *sqlRepository) getParticipants(ctx context.Context, m *model.MediaFile) (model.Participants, error) { - ar := NewArtistRepository(r.db) +func (r *sqlRepository) getParticipants(m *model.MediaFile) (model.Participants, error) { + ar := NewArtistRepository(r.ctx, r.db) ids := m.Participants.AllIDs() - artists, err := ar.GetAll(ctx, model.QueryOptions{Filters: Eq{"artist.id": ids}}) + artists, err := ar.GetAll(model.QueryOptions{Filters: Eq{"artist.id": ids}}) if err != nil { return nil, fmt.Errorf("getting participants: %w", err) } diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index a758cb847..02162387c 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, Like{f: fmt.Sprintf("%s%%", v)}) + filters = append(filters, startsWithFilter(f, v)) } return filters } @@ -54,7 +54,7 @@ func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.Query func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.QueryOptions) model.QueryOptions { qo := model.QueryOptions{} if len(options) > 0 { - qo.Sort, qo.Order = r.sanitizeSort(ctx, options[0].Sort, options[0].Order) + qo.Sort, qo.Order = r.sanitizeSort(options[0].Sort, options[0].Order) qo.Max = options[0].Max qo.Offset = options[0].Offset if seed, ok := options[0].Filters["seed"].(string); ok { @@ -66,14 +66,16 @@ func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.Qu return qo } -func (r sqlRepository) sanitizeSort(ctx context.Context, sort, order string) (string, string) { +func (r sqlRepository) sanitizeSort(sort, order string) (string, string) { if sort != "" { sort = toSnakeCase(sort) - // Validate only: buildSortOrder resolves the mapping later, and mapping here as well would - // feed sortMapping its own output. - if _, _, known := r.lookupSortMapping(sort); !known && !r.isFieldWhiteListed(sort) { - log.Warn(ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) - sort = "" + if mapped, ok := r.sortMappings[sort]; ok { + sort = mapped + } else { + if !r.isFieldWhiteListed(sort) { + log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) + sort = "" + } } } if order != "" { @@ -89,10 +91,8 @@ func eqFilter(field string, value any) Sqlizer { return Eq{field: value} } -func startsWithFilter(field string) func(string, any) Sqlizer { - return func(_ string, value any) Sqlizer { - return Like{field: fmt.Sprintf("%s%%", value)} - } +func startsWithFilter(field string, value any) Sqlizer { + return Like{field: fmt.Sprintf("%s%%", value)} } func containsFilter(field string) func(string, any) Sqlizer { @@ -129,9 +129,11 @@ func idFilter(tableName string) func(string, any) Sqlizer { return func(field string, value any) Sqlizer { return Eq{tableName + ".id": value} } } -func invalidFilter(field string, value any) Sqlizer { - log.Warn("Invalid filter", "fieldName", field, "value", value) - return Eq{"1": "0"} +func invalidFilter(ctx context.Context) func(string, any) Sqlizer { + return func(field string, value any) Sqlizer { + log.Warn(ctx, "Invalid filter", "fieldName", field, "value", value) + return Eq{"1": "0"} + } } var ( diff --git a/persistence/sql_search.go b/persistence/sql_search.go index aa8707247..43965ebb7 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -1,8 +1,6 @@ package persistence import ( - "context" - "fmt" "strings" . "github.com/Masterminds/squirrel" @@ -22,9 +20,8 @@ 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 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 overrides the default applyLibraryFilter for FTS Phase 1. + // Needed when library access requires a junction table (e.g. artist → library_artist). LibraryFilter func(sq SelectBuilder) SelectBuilder } @@ -33,7 +30,7 @@ type searchConfig struct { // including FTS Phase 1 which builds its own query outside sq. type searchStrategy interface { Sqlizer - execute(ctx context.Context, r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error + execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error } // getSearchStrategy returns the appropriate search strategy based on config and query content. @@ -52,7 +49,7 @@ func getSearchStrategy(tableName, query string) searchStrategy { // otherwise delegates to getSearchStrategy. sq must already have LIMIT/OFFSET set // via newSelect(options...). options is forwarded so FTS Phase 1 can apply the same // filters and pagination independently. -func (r sqlRepository) doSearch(ctx context.Context, sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error { +func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error { q = strings.TrimSpace(q) q = strings.TrimSuffix(q, "*") @@ -60,14 +57,14 @@ func (r sqlRepository) doSearch(ctx context.Context, sq SelectBuilder, q string, // Empty query (OpenSubsonic `search3?query=""`) — return all in natural order. if q == "" || q == `""` { - rowidCore := Select(r.tableName + ".rowid").From(r.tableName).OrderBy(cfg.NaturalOrder) - return r.executeTwoPhase(ctx, sq, results, rowidCore, cfg, options) + sq = sq.OrderBy(cfg.NaturalOrder) + return r.queryAll(sq, results, options) } // MBID search: if query is a valid UUID, search by MBID fields instead if uuid.Validate(q) == nil && len(cfg.MBIDFields) > 0 { sq = sq.Where(mbidExpr(r.tableName, q, cfg.MBIDFields...)) - return r.queryAll(ctx, sq, results) + return r.queryAll(sq, results) } // Min-length guard: single-character queries are too broad for search3. @@ -82,51 +79,7 @@ func (r sqlRepository) doSearch(ctx context.Context, sq SelectBuilder, q string, return nil } - return strategy.execute(ctx, 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(ctx context.Context, 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(ctx, rowidQuery) - } - if options.Filters != nil { - rowidQuery = rowidQuery.Where(options.Filters) - } - return r.hydrateRowidPage(ctx, 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(ctx context.Context, 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(ctx, sq, results) + return strategy.execute(r, sq, results, cfg, options) } func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index ee43680e6..e9b961d91 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -1,7 +1,6 @@ package persistence import ( - "context" "fmt" "regexp" "strings" @@ -12,7 +11,6 @@ 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. @@ -37,12 +35,48 @@ 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) @@ -66,7 +100,7 @@ func processPunctuatedWords(input string, phrases []string) (string, []string) { result = append(result, w) continue } - concat := str.FTSPunctStrip.ReplaceAllString(w, "") + concat := fts5PunctStrip.ReplaceAllString(w, "") if concat == "" || concat == w { result = append(result, w) continue @@ -106,15 +140,13 @@ 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. -// The second return reports whether tokenization degraded the query (see ftsQueryDegraded). -func buildFTS5Query(userInput string) (string, bool) { +func buildFTS5Query(userInput string) string { q := strings.TrimSpace(userInput) if q == "" || q == `""` { - return "", false + return "" } var phrases []string @@ -154,38 +186,25 @@ func buildFTS5Query(userInput string) (string, bool) { result = fts5LeadingStar.ReplaceAllString(result, "$1") tokens := strings.Fields(result) - // 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)) + // Append * to plain tokens for prefix matching (e.g., "love" → "love*"). + // Skip tokens that are already wildcarded or are quoted phrase placeholders. for i, t := range tokens { if strings.HasPrefix(t, "\x00") || strings.HasSuffix(t, "*") { - prefixTokens[i], wrappedTokens[i] = t, t continue } - prefixTokens[i] = t + "*" - wrappedTokens[i] = "(" + t + " OR " + t + "*)" + tokens[i] = t + "*" } // Use explicit AND between tokens — FTS5's implicit AND (space-separated) - // 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 ") + // doesn't work correctly with parenthesized OR groups from processPunctuatedWords. + result = strings.Join(tokens, " 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) } - // 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) + return result } // ftsColumn pairs an FTS5 column name with its BM25 relevance weight. @@ -225,10 +244,7 @@ var ftsColumnDefs = map[string][]ftsColumn{ "artist": { {"name", 10.0}, {"sort_artist_name", 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}, + {"search_normalized", 1.0}, }, } @@ -263,15 +279,17 @@ type ftsSearch struct { } // ToSql returns a single-query fallback for the REST filter path (no two-phase split). -func (s *ftsSearch) ToSql() (string, []any, error) { +func (s *ftsSearch) ToSql() (string, []interface{}, error) { sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)" - return sql, []any{s.matchExpr}, nil + return sql, []interface{}{s.matchExpr}, nil } -// 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(ctx context.Context, r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { +// 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. +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 { if qualified := qualifyOrderBy(s.tableName, ob); qualified != "" { @@ -279,11 +297,45 @@ func (s *ftsSearch) execute(ctx context.Context, r sqlRepository, sq SelectBuild } } - rowidCore := Select(s.tableName+".rowid"). + // 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"). 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...) - return r.executeTwoPhase(ctx, sq, dest, rowidCore, cfg, options) + 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) } // qualifyOrderBy prepends tableName to a simple column name. Returns empty string for @@ -313,7 +365,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 := str.FTSPunctStrip.ReplaceAllString(stripped, "") + alphaNum := fts5PunctStrip.ReplaceAllString(stripped, "") // If the original is entirely alphanumeric, nothing was stripped — not degraded if len(alphaNum) == len(stripped) { return false @@ -321,8 +373,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.FieldsSeq(ftsQuery) - for t := range tokens { + tokens := strings.Fields(ftsQuery) + for _, t := range tokens { t = strings.TrimSuffix(t, "*") // Skip internal phrase placeholders if strings.HasPrefix(t, "\x00") { @@ -337,8 +389,8 @@ func ftsQueryDegraded(original, ftsQuery string) bool { if strings.HasPrefix(t, `"`) { // Extract content between quotes inner := strings.Trim(t, `"`) - innerAlpha := str.FTSPunctStrip.ReplaceAllString(inner, " ") - for it := range strings.FieldsSeq(innerAlpha) { + innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ") + for _, it := range strings.Fields(innerAlpha) { if len(it) > 2 { return false } @@ -357,8 +409,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, degraded := buildFTS5Query(query) - if q == "" || degraded { + q := buildFTS5Query(query) + if q == "" || ftsQueryDegraded(query, q) { // 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 5d52cb1e8..b54e5856a 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -12,45 +12,44 @@ import ( var _ = DescribeTable("buildFTS5Query", func(input, expected string) { - q, _ := buildFTS5Query(input) - Expect(q).To(Equal(expected)) + Expect(buildFTS5Query(input)).To(Equal(expected)) }, Entry("returns empty string for empty input", "", ""), Entry("returns empty string for whitespace-only input", " ", ""), - 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("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("strips standalone *", "*", ""), - Entry("strips apostrophe from input", "Guns N' Roses", "(Guns OR Guns*) AND (N OR N*) AND (Roses OR Roses*)"), + Entry("strips apostrophe from input", "Guns N' Roses", "Guns* AND N* AND 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 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("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("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 OR best*) AND (of OR of*) AND "R E M"`), + Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND 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 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("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("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", "!!!!!!!", ""), @@ -58,20 +57,6 @@ 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)) @@ -89,6 +74,28 @@ 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)) @@ -158,7 +165,7 @@ var _ = Describe("ftsColumnDefs helpers", func() { It("returns weight CSV for artist", func() { Expect(ftsBM25Weights).To(HaveKeyWithValue("artist", - "10.0, 1.0, 10.0", + "10.0, 1.0, 1.0", )) }) @@ -252,18 +259,18 @@ var _ = Describe("newFTSSearch", func() { Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank")) }) - It("wraps query with column filter", func() { + It("wraps query with column filter for known tables", 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 OR Beatles*))")) + Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : (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 OR test*)")) + Expect(fts.matchExpr).To(Equal("test*")) }) It("preserves phrase queries inside column filter", func() { @@ -317,20 +324,20 @@ var _ = Describe("FTS5 Integration Search", func() { mr model.MediaFileRepository alr model.AlbumRepository arr model.ArtistRepository - ctx context.Context ) BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) conn := GetDBXBuilder() - mr = NewMediaFileRepository(conn) - alr = NewAlbumRepository(conn) - arr = NewArtistRepository(conn) + mr = NewMediaFileRepository(ctx, conn) + alr = NewAlbumRepository(ctx, conn) + arr = NewArtistRepository(ctx, conn) }) Describe("MediaFile search", func() { It("finds media files by title", func() { - results, err := mr.Search(ctx, "Radioactivity", model.QueryOptions{Max: 10}) + results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Title).To(Equal("Radioactivity")) @@ -338,7 +345,7 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds media files by artist name", func() { - results, err := mr.Search(ctx, "Beatles", model.QueryOptions{Max: 10}) + results, err := mr.Search("Beatles", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(3)) for _, r := range results { @@ -349,7 +356,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Album search", func() { It("finds albums by name", func() { - results, err := alr.Search(ctx, "Sgt Peppers", model.QueryOptions{Max: 10}) + results, err := alr.Search("Sgt Peppers", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("Sgt Peppers")) @@ -357,7 +364,7 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds albums with multi-word search", func() { - results, err := alr.Search(ctx, "Abbey Road", model.QueryOptions{Max: 10}) + results, err := alr.Search("Abbey Road", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(2)) }) @@ -365,7 +372,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Artist search", func() { It("finds artists by name", func() { - results, err := arr.Search(ctx, "Kraftwerk", model.QueryOptions{Max: 10}) + results, err := arr.Search("Kraftwerk", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("Kraftwerk")) @@ -375,7 +382,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("CJK search", func() { It("finds media files by CJK title", func() { - results, err := mr.Search(ctx, "プラチナ", model.QueryOptions{Max: 10}) + results, err := mr.Search("プラチナ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Title).To(Equal("プラチナ・ジェット")) @@ -383,14 +390,14 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds media files by CJK artist name", func() { - results, err := mr.Search(ctx, "シートベルツ", model.QueryOptions{Max: 10}) + results, err := mr.Search("シートベルツ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Artist).To(Equal("シートベルツ")) }) It("finds albums by CJK artist name", func() { - results, err := alr.Search(ctx, "シートベルツ", model.QueryOptions{Max: 10}) + results, err := alr.Search("シートベルツ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("COWBOY BEBOP")) @@ -398,7 +405,7 @@ var _ = Describe("FTS5 Integration Search", func() { }) It("finds artists by CJK name", func() { - results, err := arr.Search(ctx, "シートベルツ", model.QueryOptions{Max: 10}) + results, err := arr.Search("シートベルツ", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Name).To(Equal("シートベルツ")) @@ -408,7 +415,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Album version search", func() { It("finds albums by version tag via FTS", func() { - results, err := alr.Search(ctx, "Deluxe", model.QueryOptions{Max: 10}) + results, err := alr.Search("Deluxe", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal(albumWithVersion.ID)) @@ -417,7 +424,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Punctuation-only search", func() { It("finds media files with punctuation-only title", func() { - results, err := mr.Search(ctx, "!!!!!!!", model.QueryOptions{Max: 10}) + results, err := mr.Search("!!!!!!!", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Title).To(Equal("!!!!!!!")) @@ -427,7 +434,7 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Single-character search (doSearch min-length guard)", func() { It("returns empty results for single-char query via Search", func() { - results, err := mr.Search(ctx, "a", model.QueryOptions{Max: 10}) + results, err := mr.Search("a", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty(), "doSearch should reject single-char queries") }) @@ -435,43 +442,9 @@ var _ = Describe("FTS5 Integration Search", func() { Describe("Max=0 means no limit (regression: must not produce LIMIT 0)", func() { It("returns results with Max=0", func() { - results, err := mr.Search(ctx, "Beatles", model.QueryOptions{Max: 0}) + results, err := mr.Search("Beatles", model.QueryOptions{Max: 0}) Expect(err).ToNot(HaveOccurred()) 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(ctx, arr, &a, 1)).To(Succeed()) - } - }) - - It("ranks the exact transliterated match first for 'MO'", func() { - results, err := arr.Search(ctx, "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(ctx, "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 c7d94cc04..769a911d5 100644 --- a/persistence/sql_search_like.go +++ b/persistence/sql_search_like.go @@ -1,7 +1,6 @@ package persistence import ( - "context" "strings" . "github.com/Masterminds/squirrel" @@ -17,14 +16,14 @@ type likeSearch struct { filter Sqlizer } -func (s *likeSearch) ToSql() (string, []any, error) { +func (s *likeSearch) ToSql() (string, []interface{}, error) { return s.filter.ToSql() } -func (s *likeSearch) execute(ctx context.Context, r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { +func (s *likeSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { sq = sq.Where(s.filter) sq = sq.OrderBy(cfg.OrderBy...) - return r.queryAll(ctx, sq, dest, options) + return r.queryAll(sq, dest, options) } // newLegacySearch creates a LIKE search against the full_text column. diff --git a/persistence/sql_search_like_test.go b/persistence/sql_search_like_test.go index 2bfeba29b..8ee4ef93c 100644 --- a/persistence/sql_search_like_test.go +++ b/persistence/sql_search_like_test.go @@ -102,32 +102,32 @@ var _ = Describe("likeSearchExpr", func() { var _ = Describe("Legacy Integration Search", func() { var mr model.MediaFileRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) DeferCleanup(configtest.SetupConfig()) conf.Server.Search.Backend = "legacy" + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) conn := GetDBXBuilder() - mr = NewMediaFileRepository(conn) + mr = NewMediaFileRepository(ctx, conn) }) It("returns results using legacy LIKE-based search", func() { - results, err := mr.Search(ctx, "Radioactivity", model.QueryOptions{Max: 10}) + results, err := mr.Search("Radioactivity", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(HaveLen(1)) Expect(results[0].Title).To(Equal("Radioactivity")) }) It("returns empty results for single-char query (doSearch min-length guard)", func() { - results, err := mr.Search(ctx, "a", model.QueryOptions{Max: 10}) + results, err := mr.Search("a", model.QueryOptions{Max: 10}) Expect(err).ToNot(HaveOccurred()) Expect(results).To(BeEmpty(), "doSearch should reject single-char queries") }) It("returns results with Max=0 (regression: must not produce LIMIT 0)", func() { - results, err := mr.Search(ctx, "Beatles", model.QueryOptions{Max: 0}) + results, err := mr.Search("Beatles", model.QueryOptions{Max: 0}) Expect(err).ToNot(HaveOccurred()) Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0") }) diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go index 7e1978453..88acebb7f 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -48,78 +48,6 @@ func marshalTags(tags model.Tags) string { return string(res) } -// indexedTagNames are the tag types materialized into the
_tags join tables, so filtering by -// them is an index-backed semi-join instead of a per-row json_tree(tags) scan. Genre only for now. -var indexedTagNames = []model.TagName{model.TagGenre} - -// updateTags rewrites this item's
_tags rows from its in-memory tags, mirroring -// updateParticipants (delete-then-insert in the same Put; JOIN to tag skips not-yet-saved ids). -func (r sqlRepository) updateTags(ctx context.Context, itemID string, tags model.Tags) error { - del := Delete(r.tableName + "_tags").Where(Eq{r.tableName + "_id": itemID}) - if _, err := r.executeSQL(ctx, del); err != nil { - return err - } - var tagIDs []string - for _, name := range indexedTagNames { - for _, value := range tags.Values(name) { - tagIDs = append(tagIDs, model.NewTag(name, value).ID) - } - } - if len(tagIDs) == 0 { - return nil - } - idsJSON, err := json.Marshal(tagIDs) - if err != nil { - return fmt.Errorf("marshaling tag ids: %w", err) - } - query := fmt.Sprintf(` - INSERT INTO %[1]s_tags (%[1]s_id, tag_id) - SELECT ?, value FROM json_each(?) - JOIN tag ON tag.id = value - ON CONFLICT (%[1]s_id, tag_id) DO NOTHING`, r.tableName) - _, err = r.executeSQL(ctx, Expr(query, itemID, string(idsJSON))) - return err -} - -// genreFilterDef builds indexed genre filters for one item type. Callers use the exported SongGenres / AlbumGenres instances. -type genreFilterDef struct{ idCol, table, joinCol string } - -var ( - SongGenres = genreFilterDef{"media_file.id", "media_file_tags", "media_file_id"} - AlbumGenres = genreFilterDef{"album.id", "album_tags", "album_id"} -) - -// ByID matches items tagged with any of the given genre tag ids (scalar or slice). -func (g genreFilterDef) ByID(tagIDs any) Sqlizer { - sub, args, _ := Select(g.joinCol).From(g.table).Where(Eq{"tag_id": tagIDs}).ToSql() - return Expr(g.idCol+" IN ("+sub+")", args...) -} - -// ByName matches by genre name (Subsonic passes a name, not an id), resolved through the tag -// dictionary, which is uniquely indexed on (tag_name, tag_value). -func (g genreFilterDef) ByName(genre string) Sqlizer { - sub, args, _ := Select("jt." + g.joinCol).From(g.table + " jt"). - Join("tag on tag.id = jt.tag_id"). - Where(And{Eq{"tag.tag_name": "genre"}, Like{"tag.tag_value": genre}}).ToSql() - return Expr(g.idCol+" IN ("+sub+")", args...) -} - -// AlbumArtistsByGenreID matches album artists of albums tagged with any of the genre ids. It's a -// two-table join (album_artists ⨝ album_tags), so it doesn't fit the single-table genreFilterDef. -func AlbumArtistsByGenreID(tagIDs []string) Sqlizer { - sub, args, _ := Select("aa.artist_id").From("album_artists aa"). - Join("album_tags at on at.album_id = aa.album_id"). - Where(And{Eq{"aa.role": "albumartist"}, Eq{"at.tag_id": tagIDs}}).ToSql() - return Expr("artist.id IN ("+sub+")", args...) -} - -func genreFilter(filter genreFilterDef) func(_ string, v any) Sqlizer { - return func(_ string, v any) Sqlizer { - return filter.ByID(v) - } -} - -// tagIDFilter matches rows whose tags JSON contains the tag id(s); a "_id" key maps to "$.". func tagIDFilter(name string, idValue any) Sqlizer { name = strings.TrimSuffix(name, "_id") return Exists( @@ -146,10 +74,11 @@ type baseTagRepository struct { // newBaseTagRepository creates a new base tag repository with optional tag filtering. // If tagFilter is nil, the repository will work with all tags. // If tagFilter is provided, the repository will only work with tags of that specific name. -func newBaseTagRepository(db dbx.Builder, tagFilter *model.TagName) *baseTagRepository { +func newBaseTagRepository(ctx context.Context, db dbx.Builder, tagFilter *model.TagName) *baseTagRepository { r := &baseTagRepository{ tagFilter: tagFilter, } + r.ctx = ctx r.db = db r.tableName = "tag" r.registerModel(&model.Tag{}, map[string]filterFunc{ @@ -163,12 +92,12 @@ func newBaseTagRepository(db dbx.Builder, tagFilter *model.TagName) *baseTagRepo } // applyLibraryFiltering adds the appropriate library joins based on user context -func (r *baseTagRepository) applyLibraryFiltering(ctx context.Context, sq SelectBuilder) SelectBuilder { +func (r *baseTagRepository) applyLibraryFiltering(sq SelectBuilder) SelectBuilder { // Add library_tag join sq = sq.LeftJoin("library_tag on library_tag.tag_id = tag.id") // For authenticated users, also join with user_library to filter by accessible libraries - user := loggedUser(ctx) + user := loggedUser(r.ctx) if user.ID != invalidUserId { sq = sq.Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID) } @@ -177,8 +106,8 @@ func (r *baseTagRepository) applyLibraryFiltering(ctx context.Context, sq Select } // newSelect overrides the base implementation to apply tag name filtering and library filtering. -func (r *baseTagRepository) newSelect(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - sq := r.sqlRepository.newSelect(ctx, options...) +func (r *baseTagRepository) newSelect(options ...model.QueryOptions) SelectBuilder { + sq := r.sqlRepository.newSelect(options...) // Apply tag name filtering if specified if r.tagFilter != nil { @@ -186,7 +115,7 @@ func (r *baseTagRepository) newSelect(ctx context.Context, options ...model.Quer } // Apply library filtering and set up aggregation columns - sq = r.applyLibraryFiltering(ctx, sq).Columns( + sq = r.applyLibraryFiltering(sq).Columns( "tag.id", "tag.tag_name", "tag.tag_value", @@ -197,9 +126,9 @@ func (r *baseTagRepository) newSelect(ctx context.Context, options ...model.Quer return sq } -// REST interface methods +// ResourceRepository interface implementation -func (r *baseTagRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { +func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) { sq := Select("COUNT(DISTINCT tag.id)").From("tag") // Apply tag name filtering if specified @@ -208,24 +137,32 @@ func (r *baseTagRepository) Count(ctx context.Context, options ...rest.QueryOpti } // Apply library filtering - sq = r.applyLibraryFiltering(ctx, sq) + sq = r.applyLibraryFiltering(sq) - return r.count(ctx, sq, r.parseRestOptions(ctx, options...)) + return r.count(sq, r.parseRestOptions(r.ctx, options...)) } -func (r *baseTagRepository) Read(ctx context.Context, id string) (*model.Tag, error) { - query := r.newSelect(ctx).Where(Eq{"id": id}) +func (r *baseTagRepository) Read(id string) (any, error) { + query := r.newSelect().Where(Eq{"id": id}) var res model.Tag - err := r.queryOne(ctx, query, &res) + err := r.queryOne(query, &res) return &res, err } -func (r *baseTagRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Tag, error) { - query := r.newSelect(ctx, r.parseRestOptions(ctx, options...)) +func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + query := r.newSelect(r.parseRestOptions(r.ctx, options...)) var res model.TagList - err := r.queryAll(ctx, query, &res) + err := r.queryAll(query, &res) return res, err } +func (r *baseTagRepository) EntityName() string { + return "tag" +} + +func (r *baseTagRepository) NewInstance() any { + return model.Tag{} +} + // Interface compliance check -var _ rest.Repository[model.Tag] = (*baseTagRepository)(nil) +var _ model.ResourceRepository = (*baseTagRepository)(nil) diff --git a/persistence/tag_library_filtering_test.go b/persistence/tag_library_filtering_test.go index a4382dccf..ddd897165 100644 --- a/persistence/tag_library_filtering_test.go +++ b/persistence/tag_library_filtering_test.go @@ -78,11 +78,11 @@ var _ = Describe("Tag Library Filtering", func() { // Create test tags adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) - tagRepo := NewTagRepository(GetDBXBuilder()) + tagRepo := NewTagRepository(adminCtx, GetDBXBuilder()) createTag := func(libraryID int, name, value string) { tag := model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} - err := tagRepo.Add(adminCtx, libraryID, tag) + err := tagRepo.Add(libraryID, tag) Expect(err).ToNot(HaveOccurred()) } @@ -119,16 +119,17 @@ var _ = Describe("Tag Library Filtering", func() { ctx = context.Background() // Headless context } - repo := NewTagRepository(GetDBXBuilder()) + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) var opts rest.QueryOptions if len(filters) > 0 { opts = filters[0] } - tags, err := repo.ReadAll(ctx, opts) + tags, err := repo.ReadAll(opts) Expect(err).ToNot(HaveOccurred()) - return tags + return tags.(model.TagList) } // Helper to count tags @@ -140,9 +141,10 @@ var _ = Describe("Tag Library Filtering", func() { ctx = context.Background() } - repo := NewTagRepository(GetDBXBuilder()) + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) - count, err := repo.Count(ctx) + count, err := repo.Count() Expect(err).ToNot(HaveOccurred()) return count } diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go index d13dece66..5bb8b3832 100644 --- a/persistence/tag_repository.go +++ b/persistence/tag_repository.go @@ -7,7 +7,6 @@ import ( "time" . "github.com/Masterminds/squirrel" - "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" @@ -17,20 +16,20 @@ type tagRepository struct { *baseTagRepository } -func NewTagRepository(db dbx.Builder) model.TagRepository { +func NewTagRepository(ctx context.Context, db dbx.Builder) model.TagRepository { return &tagRepository{ - baseTagRepository: newBaseTagRepository(db, nil), // nil = no filter, works with all tags + baseTagRepository: newBaseTagRepository(ctx, db, nil), // nil = no filter, works with all tags } } -func (r *tagRepository) Add(ctx context.Context, libraryID int, tags ...model.Tag) error { +func (r *tagRepository) Add(libraryID int, tags ...model.Tag) error { for chunk := range slices.Chunk(tags, 200) { sq := Insert(r.tableName).Columns("id", "tag_name", "tag_value"). Suffix("on conflict (id) do nothing") for _, t := range chunk { sq = sq.Values(t.ID, t.TagName, t.TagValue) } - _, err := r.executeSQL(ctx, sq) + _, err := r.executeSQL(sq) if err != nil { return err } @@ -41,7 +40,7 @@ func (r *tagRepository) Add(ctx context.Context, libraryID int, tags ...model.Ta for _, t := range chunk { libSq = libSq.Values(t.ID, libraryID, 0, 0) } - _, err = r.executeSQL(ctx, libSq) + _, err = r.executeSQL(libSq) if err != nil { return fmt.Errorf("adding library_tag entries: %w", err) } @@ -51,7 +50,7 @@ func (r *tagRepository) Add(ctx context.Context, libraryID int, tags ...model.Ta // UpdateCounts updates the library_tag table with per-library statistics. // Only genres are being updated for now. -func (r *tagRepository) UpdateCounts(ctx context.Context) error { +func (r *tagRepository) UpdateCounts() error { template := ` INSERT INTO library_tag (tag_id, library_id, %[1]s_count) SELECT jt.value as tag_id, %[1]s.library_id, count(distinct %[1]s.id) as %[1]s_count @@ -66,8 +65,8 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count; for _, table := range []string{"album", "media_file"} { start := time.Now() query := Expr(fmt.Sprintf(template, table)) - c, err := r.executeSQL(ctx, query) - log.Debug(ctx, "Updated library tag counts", "table", table, "elapsed", time.Since(start), "updated", c) + c, err := r.executeSQL(query) + log.Debug(r.ctx, "Updated library tag counts", "table", table, "elapsed", time.Since(start), "updated", c) if err != nil { return fmt.Errorf("updating %s library tag counts: %w", table, err) } @@ -75,33 +74,26 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count; return nil } -func (r *tagRepository) GetAll(ctx context.Context, name model.TagName, options ...model.QueryOptions) (model.TagList, error) { - sq := r.newSelect(ctx, options...).Where(Eq{"tag.tag_name": name}) - res := model.TagList{} - err := r.queryAll(ctx, sq, &res) - return res, err -} - -func (r *tagRepository) purgeUnused(ctx context.Context) error { - del := Delete(r.tableName).Where(` +func (r *tagRepository) purgeUnused() error { + del := Delete(r.tableName).Where(` id not in (select jt.value from album left join json_tree(album.tags, '$') as jt where atom is not null and key = 'id' - UNION + UNION select jt.value from media_file left join json_tree(media_file.tags, '$') as jt where atom is not null and key = 'id') `) - c, err := r.executeSQL(ctx, del) + c, err := r.executeSQL(del) if err != nil { return fmt.Errorf("error purging %s unused tags: %w", r.tableName, err) } if c > 0 { - log.Debug(ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName) + log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName) } return err } -var _ rest.Repository[model.Tag] = &tagRepository{} +var _ model.ResourceRepository = &tagRepository{} diff --git a/persistence/tag_repository_test.go b/persistence/tag_repository_test.go index 730d26357..9a019c30e 100644 --- a/persistence/tag_repository_test.go +++ b/persistence/tag_repository_test.go @@ -18,15 +18,15 @@ import ( var _ = Describe("TagRepository", func() { var repo model.TagRepository - var restRepo rest.Repository[model.Tag] + var restRepo model.ResourceRepository var ctx context.Context BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) - tagRepo := NewTagRepository(GetDBXBuilder()) + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) repo = tagRepo - restRepo = tagRepo + restRepo = tagRepo.(model.ResourceRepository) // Clean the database before each test to ensure isolation db := GetDBXBuilder() @@ -48,7 +48,7 @@ var _ = Describe("TagRepository", func() { return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} } - err = repo.Add(ctx, 1, + err = repo.Add(1, // Genre tags newTag("genre", "rock"), newTag("genre", "pop"), @@ -84,16 +84,17 @@ var _ = Describe("TagRepository", func() { TagValue: "experimental", } - err := repo.Add(ctx, 1, newTag) + err := repo.Add(1, newTag) Expect(err).ToNot(HaveOccurred()) // Verify tag was added - resultTag, err := restRepo.Read(ctx, newTag.ID) + result, err := restRepo.Read(newTag.ID) Expect(err).ToNot(HaveOccurred()) + resultTag := result.(*model.Tag) Expect(resultTag.TagValue).To(Equal("experimental")) // Check count increased - count, err := restRepo.Count(ctx) + count, err := restRepo.Count() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(21))) // 20 from dataset + 1 new }) @@ -106,15 +107,15 @@ var _ = Describe("TagRepository", func() { TagValue: "rock", } - count, err := restRepo.Count(ctx) + count, err := restRepo.Count() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(20))) // Still 20 tags - err = repo.Add(ctx, 1, duplicateTag) + err = repo.Add(1, duplicateTag) Expect(err).ToNot(HaveOccurred()) // Should not error // Count should remain the same - count, err = restRepo.Count(ctx) + count, err = restRepo.Count() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(20))) // Still 20 tags }) @@ -122,7 +123,7 @@ var _ = Describe("TagRepository", func() { Describe("UpdateCounts", func() { It("should update tag counts successfully", func() { - err := repo.UpdateCounts(ctx) + err := repo.UpdateCounts() Expect(err).ToNot(HaveOccurred()) }) @@ -132,7 +133,7 @@ var _ = Describe("TagRepository", func() { _, err := db.NewQuery("DELETE FROM tag").Execute() Expect(err).ToNot(HaveOccurred()) - err = repo.UpdateCounts(ctx) + err = repo.UpdateCounts() Expect(err).ToNot(HaveOccurred()) }) @@ -158,7 +159,7 @@ var _ = Describe("TagRepository", func() { Expect(err).ToNot(HaveOccurred()) // This should not fail with foreign key constraint error - err = repo.UpdateCounts(ctx) + err = repo.UpdateCounts() Expect(err).ToNot(HaveOccurred()) // Cleanup @@ -188,7 +189,7 @@ var _ = Describe("TagRepository", func() { Expect(err).ToNot(HaveOccurred()) // This should not fail with foreign key constraint error - err = repo.UpdateCounts(ctx) + err = repo.UpdateCounts() Expect(err).ToNot(HaveOccurred()) // Cleanup @@ -200,7 +201,7 @@ var _ = Describe("TagRepository", func() { Describe("Count", func() { It("should return correct count of tags", func() { - count, err := restRepo.Count(ctx) + count, err := restRepo.Count() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(20))) // From the test dataset }) @@ -209,23 +210,25 @@ var _ = Describe("TagRepository", func() { Describe("Read", func() { It("should return existing tag", func() { rockID := id.NewTagID("genre", "rock") - resultTag, err := restRepo.Read(ctx, rockID) + result, err := restRepo.Read(rockID) Expect(err).ToNot(HaveOccurred()) + resultTag := result.(*model.Tag) Expect(resultTag.ID).To(Equal(rockID)) Expect(resultTag.TagName).To(Equal(model.TagName("genre"))) Expect(resultTag.TagValue).To(Equal("rock")) }) It("should return error for non-existent tag", func() { - _, err := restRepo.Read(ctx, "non-existent-id") + _, err := restRepo.Read("non-existent-id") Expect(err).To(HaveOccurred()) }) }) Describe("ReadAll", func() { It("should return all tags from dataset", func() { - tags, err := restRepo.ReadAll(ctx) + result, err := restRepo.ReadAll() Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) Expect(tags).To(HaveLen(20)) }) @@ -233,8 +236,9 @@ var _ = Describe("TagRepository", func() { options := rest.QueryOptions{ Filters: map[string]any{"name": "%rock%"}, // Tags containing 'rock' } - tags, err := restRepo.ReadAll(ctx, options) + result, err := restRepo.ReadAll(options) Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) Expect(tags).To(HaveLen(2)) // "rock" and "Alternative Rock" // Verify all returned tags contain 'rock' in their value @@ -247,8 +251,9 @@ var _ = Describe("TagRepository", func() { options := rest.QueryOptions{ Filters: map[string]any{"name": "%e%"}, // Tags containing 'e' } - tags, err := restRepo.ReadAll(ctx, options) + result, err := restRepo.ReadAll(options) Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) Expect(tags).To(HaveLen(8)) // electronic, house, trance, energetic, Blues, decade x2, Alternative Rock // Verify all returned tags contain 'e' in their value @@ -263,8 +268,9 @@ var _ = Describe("TagRepository", func() { Sort: "name", Order: "asc", } - tags, err := restRepo.ReadAll(ctx, options) + result, err := restRepo.ReadAll(options) Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) Expect(tags).To(HaveLen(7)) Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int { @@ -278,8 +284,9 @@ var _ = Describe("TagRepository", func() { Sort: "name", Order: "desc", } - tags, err := restRepo.ReadAll(ctx, options) + result, err := restRepo.ReadAll(options) Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) Expect(tags).To(HaveLen(7)) Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int { @@ -287,4 +294,18 @@ var _ = Describe("TagRepository", func() { })) }) }) + + Describe("EntityName", func() { + It("should return correct entity name", func() { + name := restRepo.EntityName() + Expect(name).To(Equal("tag")) + }) + }) + + Describe("NewInstance", func() { + It("should return new tag instance", func() { + instance := restRepo.NewInstance() + Expect(instance).To(BeAssignableToTypeOf(model.Tag{})) + }) + }) }) diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index 9db56e143..870da61c8 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "errors" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -13,98 +14,99 @@ type transcodingRepository struct { sqlRepository } -func NewTranscodingRepository(db dbx.Builder) model.TranscodingRepository { +func NewTranscodingRepository(ctx context.Context, db dbx.Builder) model.TranscodingRepository { r := &transcodingRepository{} + r.ctx = ctx r.db = db r.registerModel(&model.Transcoding{}, nil) return r } -func (r *transcodingRepository) Get(ctx context.Context, id string) (*model.Transcoding, error) { - sel := r.newSelect(ctx).Columns("*").Where(Eq{"id": id}) +func (r *transcodingRepository) Get(id string) (*model.Transcoding, error) { + sel := r.newSelect().Columns("*").Where(Eq{"id": id}) var res model.Transcoding - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *transcodingRepository) CountAll(ctx context.Context, qo ...model.QueryOptions) (int64, error) { - return r.count(ctx, Select(), qo...) +func (r *transcodingRepository) CountAll(qo ...model.QueryOptions) (int64, error) { + return r.count(Select(), qo...) } -func (r *transcodingRepository) FindByFormat(ctx context.Context, format string) (*model.Transcoding, error) { - sel := r.newSelect(ctx).Columns("*").Where(Eq{"target_format": format}) +func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding, error) { + sel := r.newSelect().Columns("*").Where(Eq{"target_format": format}) var res model.Transcoding - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *transcodingRepository) Put(ctx context.Context, t *model.Transcoding) error { - if !loggedUser(ctx).IsAdmin { +func (r *transcodingRepository) Put(t *model.Transcoding) error { + if !loggedUser(r.ctx).IsAdmin { return rest.ErrPermissionDenied } - _, err := r.put(ctx, t.ID, t) + _, err := r.put(t.ID, t) return err } -func (r *transcodingRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.count(ctx, Select(), r.parseRestOptions(ctx, options...)) +func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.count(Select(), r.parseRestOptions(r.ctx, options...)) } -func (r *transcodingRepository) Read(ctx context.Context, id string) (*model.Transcoding, error) { - res, err := r.Get(ctx, id) - if err != nil { - return nil, err - } - if !loggedUser(ctx).IsAdmin { - res.Command = "" - } - return res, nil +func (r *transcodingRepository) Read(id string) (any, error) { + return r.Get(id) } -func (r *transcodingRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Transcoding, error) { - sel := r.newSelect(ctx, r.parseRestOptions(ctx, options...)).Columns("*") +func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") res := model.Transcodings{} - err := r.queryAll(ctx, sel, &res) - if err != nil { - return nil, err - } - if !loggedUser(ctx).IsAdmin { - for i := range res { - res[i].Command = "" - } - } - return res, nil + err := r.queryAll(sel, &res) + return res, err } -func (r *transcodingRepository) Save(ctx context.Context, t *model.Transcoding) (string, error) { - if !loggedUser(ctx).IsAdmin { +func (r *transcodingRepository) EntityName() string { + return "transcoding" +} + +func (r *transcodingRepository) NewInstance() any { + return &model.Transcoding{} +} + +func (r *transcodingRepository) Save(entity any) (string, error) { + if !loggedUser(r.ctx).IsAdmin { return "", rest.ErrPermissionDenied } - return r.put(ctx, t.ID, t) + t := entity.(*model.Transcoding) + id, err := r.put(t.ID, t) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + return id, err } -func (r *transcodingRepository) Update(ctx context.Context, id string, entity model.Transcoding, cols ...string) error { - if !loggedUser(ctx).IsAdmin { +func (r *transcodingRepository) Update(id string, entity any, cols ...string) error { + if !loggedUser(r.ctx).IsAdmin { return rest.ErrPermissionDenied } - t := &entity + t := entity.(*model.Transcoding) t.ID = id - _, err := r.put(ctx, id, t) + _, err := r.put(id, t) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } return err } -func (r *transcodingRepository) Delete(ctx context.Context, ids ...string) error { - if !loggedUser(ctx).IsAdmin { +func (r *transcodingRepository) Delete(id string) error { + if !loggedUser(r.ctx).IsAdmin { return rest.ErrPermissionDenied } - for _, id := range ids { - if err := r.deleteByID(ctx, id); err != nil { - return err - } + err := r.delete(Eq{"id": id}) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound } - return nil + return err } var _ model.TranscodingRepository = (*transcodingRepository)(nil) -var _ rest.Repository[model.Transcoding] = (*transcodingRepository)(nil) -var _ rest.Persistable[model.Transcoding] = (*transcodingRepository)(nil) +var _ rest.Repository = (*transcodingRepository)(nil) +var _ rest.Persistable = (*transcodingRepository)(nil) diff --git a/persistence/transcoding_repository_test.go b/persistence/transcoding_repository_test.go index 372b5801f..eddc5047a 100644 --- a/persistence/transcoding_repository_test.go +++ b/persistence/transcoding_repository_test.go @@ -1,8 +1,6 @@ package persistence import ( - "context" - "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -13,141 +11,86 @@ import ( var _ = Describe("TranscodingRepository", func() { var repo model.TranscodingRepository - var ctx, adminCtx context.Context + var adminRepo model.TranscodingRepository BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), regularUser) - adminCtx = request.WithUser(ctx, adminUser) - repo = NewTranscodingRepository(GetDBXBuilder()) + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, regularUser) + repo = NewTranscodingRepository(ctx, GetDBXBuilder()) + + adminCtx := log.NewContext(GinkgoT().Context()) + adminCtx = request.WithUser(adminCtx, adminUser) + adminRepo = NewTranscodingRepository(adminCtx, GetDBXBuilder()) }) AfterEach(func() { // Clean up any transcoding created during the tests - tc, err := repo.FindByFormat(adminCtx, "test_format") + tc, err := adminRepo.FindByFormat("test_format") if err == nil { - err = repo.Delete(adminCtx, tc.ID) + err = adminRepo.(*transcodingRepository).Delete(tc.ID) Expect(err).ToNot(HaveOccurred()) } }) Describe("Admin User", func() { It("creates a new transcoding", func() { - base, err := repo.CountAll(adminCtx) + base, err := adminRepo.CountAll() Expect(err).ToNot(HaveOccurred()) - err = repo.Put(adminCtx, &model.Transcoding{ID: "new", Name: "new", TargetFormat: "test_format", DefaultBitRate: 320, Command: "ffmpeg"}) + err = adminRepo.Put(&model.Transcoding{ID: "new", Name: "new", TargetFormat: "test_format", DefaultBitRate: 320, Command: "ffmpeg"}) Expect(err).ToNot(HaveOccurred()) - count, err := repo.CountAll(adminCtx) + count, err := adminRepo.CountAll() Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(base + 1)) }) It("updates an existing transcoding", func() { tr := &model.Transcoding{ID: "upd", Name: "old", TargetFormat: "test_format", DefaultBitRate: 100, Command: "ffmpeg"} - Expect(repo.Put(adminCtx, tr)).To(Succeed()) + Expect(adminRepo.Put(tr)).To(Succeed()) tr.Name = "updated" - err := repo.Put(adminCtx, tr) + err := adminRepo.Put(tr) Expect(err).ToNot(HaveOccurred()) - res, err := repo.FindByFormat(adminCtx, "test_format") + res, err := adminRepo.FindByFormat("test_format") Expect(err).ToNot(HaveOccurred()) Expect(res.Name).To(Equal("updated")) }) It("deletes a transcoding", func() { - err := repo.Put(adminCtx, &model.Transcoding{ID: "to-delete", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 256, Command: "ffmpeg"}) + err := adminRepo.Put(&model.Transcoding{ID: "to-delete", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 256, Command: "ffmpeg"}) Expect(err).ToNot(HaveOccurred()) - err = repo.Delete(adminCtx, "to-delete") + err = adminRepo.(*transcodingRepository).Delete("to-delete") Expect(err).ToNot(HaveOccurred()) - _, err = repo.Get(adminCtx, "to-delete") + _, err = adminRepo.Get("to-delete") Expect(err).To(MatchError(model.ErrNotFound)) }) - - It("returns not found when deleting a missing transcoding", func() { - err := repo.(*transcodingRepository).Delete(adminCtx, "does-not-exist") - 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(repo.Put(adminCtx, tr)).To(Succeed()) - - res, err := repo.Read(adminCtx, "adminread") - Expect(err).ToNot(HaveOccurred()) - Expect(res.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(repo.Put(adminCtx, tr)).To(Succeed()) - - t, err := repo.Read(ctx, "readreg") - Expect(err).ToNot(HaveOccurred()) - 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(repo.Put(adminCtx, tr)).To(Succeed()) - - list, err := repo.ReadAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(list).ToNot(BeEmpty()) - for _, t := range list { - Expect(t.Command).To(BeEmpty()) - } - }) - - It("counts transcodings", func() { - count, err := repo.Count(ctx) - 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(repo.Put(adminCtx, tr)).To(Succeed()) - - res, err := repo.Get(ctx, "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(repo.Put(adminCtx, tr)).To(Succeed()) - - res, err := repo.FindByFormat(ctx, "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(ctx, &model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}) + err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}) Expect(err).To(Equal(rest.ErrPermissionDenied)) }) It("fails to update", func() { tr := &model.Transcoding{ID: "updreg", Name: "old", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"} - Expect(repo.Put(adminCtx, tr)).To(Succeed()) + Expect(adminRepo.Put(tr)).To(Succeed()) tr.Name = "bad" - err := repo.Put(ctx, tr) + err := repo.Put(tr) Expect(err).To(Equal(rest.ErrPermissionDenied)) + + //_ = adminRepo.(*transcodingRepository).Delete("updreg") }) It("fails to delete", func() { tr := &model.Transcoding{ID: "delreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"} - Expect(repo.Put(adminCtx, tr)).To(Succeed()) + Expect(adminRepo.Put(tr)).To(Succeed()) - err := repo.Delete(ctx, "delreg") + err := repo.(*transcodingRepository).Delete("delreg") Expect(err).To(Equal(rest.ErrPermissionDenied)) + + //_ = adminRepo.(*transcodingRepository).Delete("delreg") }) }) }) diff --git a/persistence/user_props_repository.go b/persistence/user_props_repository.go index 59d7d332f..9307385a2 100644 --- a/persistence/user_props_repository.go +++ b/persistence/user_props_repository.go @@ -13,16 +13,17 @@ type userPropsRepository struct { sqlRepository } -func NewUserPropsRepository(db dbx.Builder) model.UserPropsRepository { +func NewUserPropsRepository(ctx context.Context, db dbx.Builder) model.UserPropsRepository { r := &userPropsRepository{} + r.ctx = ctx r.db = db r.tableName = "user_props" return r } -func (r userPropsRepository) Put(ctx context.Context, userId, key string, value string) error { +func (r userPropsRepository) Put(userId, key string, value string) error { update := Update(r.tableName).Set("value", value).Where(And{Eq{"user_id": userId}, Eq{"key": key}}) - count, err := r.executeSQL(ctx, update) + count, err := r.executeSQL(update) if err != nil { return err } @@ -30,24 +31,24 @@ func (r userPropsRepository) Put(ctx context.Context, userId, key string, value return nil } insert := Insert(r.tableName).Columns("user_id", "key", "value").Values(userId, key, value) - _, err = r.executeSQL(ctx, insert) + _, err = r.executeSQL(insert) return err } -func (r userPropsRepository) Get(ctx context.Context, userId, key string) (string, error) { +func (r userPropsRepository) Get(userId, key string) (string, error) { sel := Select("value").From(r.tableName).Where(And{Eq{"user_id": userId}, Eq{"key": key}}) resp := struct { Value string }{} - err := r.queryOne(ctx, sel, &resp) + err := r.queryOne(sel, &resp) if err != nil { return "", err } return resp.Value, nil } -func (r userPropsRepository) DefaultGet(ctx context.Context, userId, key string, defaultValue string) (string, error) { - value, err := r.Get(ctx, userId, key) +func (r userPropsRepository) DefaultGet(userId, key string, defaultValue string) (string, error) { + value, err := r.Get(userId, key) if errors.Is(err, model.ErrNotFound) { return defaultValue, nil } @@ -57,6 +58,6 @@ func (r userPropsRepository) DefaultGet(ctx context.Context, userId, key string, return value, nil } -func (r userPropsRepository) Delete(ctx context.Context, userId, key string) error { - return r.delete(ctx, And{Eq{"user_id": userId}, Eq{"key": key}}) +func (r userPropsRepository) Delete(userId, key string) error { + return r.delete(And{Eq{"user_id": userId}, Eq{"key": key}}) } diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 20b4e5125..dc149e8ba 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -16,9 +16,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" @@ -53,24 +51,25 @@ var ( encKey []byte ) -func NewUserRepository(db dbx.Builder) model.UserRepository { +func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository { r := &userRepository{} + r.ctx = ctx r.db = db r.tableName = "user" r.registerModel(&model.User{}, map[string]filterFunc{ "id": idFilter(r.tableName), - "password": invalidFilter, - "name": startsWithFilter(r.tableName + ".name"), + "password": invalidFilter(ctx), + "name": r.withTableName(startsWithFilter), }) once.Do(func() { - _ = r.initPasswordEncryptionKey(context.Background()) + _ = r.initPasswordEncryptionKey() }) return r } // selectUserWithLibraries returns a SelectBuilder that includes library information -func (r *userRepository) selectUserWithLibraries(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - return r.newSelect(ctx, options...). +func (r *userRepository) selectUserWithLibraries(options ...model.QueryOptions) SelectBuilder { + return r.newSelect(options...). Columns(`user.*`, `COALESCE(json_group_array(json_object( 'id', library.id, @@ -88,37 +87,37 @@ func (r *userRepository) selectUserWithLibraries(ctx context.Context, options .. GroupBy("user.id") } -func (r *userRepository) CountAll(ctx context.Context, qo ...model.QueryOptions) (int64, error) { - return r.count(ctx, Select(), qo...) +func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) { + return r.count(Select(), qo...) } -func (r *userRepository) Get(ctx context.Context, id string) (*model.User, error) { - sel := r.selectUserWithLibraries(ctx).Where(Eq{"user.id": id}) +func (r *userRepository) Get(id string) (*model.User, error) { + sel := r.selectUserWithLibraries().Where(Eq{"user.id": id}) var res dbUser - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) if err != nil { return nil, err } return res.User, nil } -func (r *userRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Users, error) { - sel := r.selectUserWithLibraries(ctx, options...) +func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) { + sel := r.selectUserWithLibraries(options...) var res dbUsers - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) if err != nil { return nil, err } return res.toModels(), nil } -func (r *userRepository) Put(ctx context.Context, u *model.User) error { +func (r *userRepository) Put(u *model.User) error { if u.ID == "" { u.ID = id.NewRandom() } u.UpdatedAt = time.Now() if u.NewPassword != "" { - _ = r.encryptPassword(ctx, u) + _ = r.encryptPassword(u) } values, err := toSQLArgs(*u) if err != nil { @@ -126,34 +125,18 @@ func (r *userRepository) Put(ctx context.Context, u *model.User) error { } delete(values, "current_password") - // The epoch bump rides the password UPDATE: as two statements they can interleave with a - // concurrent change and leave a session valid that the other change should have revoked. + // Save/update the user update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values) - var isNewUser bool - var epoch int - if u.NewPassword != "" { - var res struct{ TokenEpoch int } - err = r.queryOne(ctx, update.Set("token_epoch", Expr("token_epoch + 1")). - Suffix("RETURNING token_epoch"), &res) - switch { - case errors.Is(err, model.ErrNotFound): - isNewUser = true - case err != nil: - return err - default: - epoch = res.TokenEpoch - } - } else { - count, err := r.executeSQL(ctx, update) - if err != nil { - return err - } - isNewUser = count == 0 + count, err := r.executeSQL(update) + if err != nil { + return err } + + isNewUser := count == 0 if isNewUser { values["created_at"] = time.Now() insert := Insert(r.tableName).SetMap(values) - _, err = r.executeSQL(ctx, insert) + _, err = r.executeSQL(insert) if err != nil { return err } @@ -165,7 +148,7 @@ func (r *userRepository) Put(ctx context.Context, u *model.User) error { "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library", u.ID, ) - if _, err := r.executeSQL(ctx, sql); err != nil { + if _, err := r.executeSQL(sql); err != nil { return fmt.Errorf("failed to assign all libraries to admin user: %w", err) } } else if isNewUser { // Only for new regular users @@ -174,108 +157,112 @@ func (r *userRepository) Put(ctx context.Context, u *model.User) error { "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true", u.ID, ) - if _, err := r.executeSQL(ctx, sql); err != nil { + if _, err := r.executeSQL(sql); err != nil { return fmt.Errorf("failed to assign default libraries to new user: %w", err) } } - // Only the caller's own token can be refreshed in-flight; an admin resetting another - // user must keep their own epoch. - if u.NewPassword != "" && !isNewUser && loggedUser(ctx).ID == u.ID { - request.SetTokenEpoch(ctx, epoch) - } - return nil } -func (r *userRepository) FindFirstAdmin(ctx context.Context) (*model.User, error) { - sel := r.selectUserWithLibraries(ctx, model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true}) +func (r *userRepository) FindFirstAdmin() (*model.User, error) { + sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true}) var usr dbUser - err := r.queryOne(ctx, sel, &usr) + err := r.queryOne(sel, &usr) if err != nil { return nil, err } return usr.User, nil } -func (r *userRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) { - sel := r.selectUserWithLibraries(ctx).Where(Expr("user.user_name = ? COLLATE NOCASE", username)) +func (r *userRepository) FindByUsername(username string) (*model.User, error) { + sel := r.selectUserWithLibraries().Where(Expr("user.user_name = ? COLLATE NOCASE", username)) var usr dbUser - err := r.queryOne(ctx, sel, &usr) + err := r.queryOne(sel, &usr) if err != nil { return nil, err } return usr.User, nil } -func (r *userRepository) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) { - usr, err := r.FindByUsername(ctx, username) +func (r *userRepository) FindByUsernameWithPassword(username string) (*model.User, error) { + usr, err := r.FindByUsername(username) if err != nil { return nil, err } - _ = r.decryptPassword(ctx, usr) + _ = r.decryptPassword(usr) return usr, nil } -func (r *userRepository) UpdateLastLoginAt(ctx context.Context, id string) error { +func (r *userRepository) UpdateLastLoginAt(id string) error { upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now()) - _, err := r.executeSQL(ctx, upd) + _, err := r.executeSQL(upd) return err } -func (r *userRepository) UpdateLastAccessAt(ctx context.Context, id string) error { +func (r *userRepository) UpdateLastAccessAt(id string) error { now := time.Now() upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_access_at", now) - _, err := r.executeSQL(ctx, upd) + _, err := r.executeSQL(upd) return err } -func (r *userRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - usr := loggedUser(ctx) +func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) { + usr := loggedUser(r.ctx) if !usr.IsAdmin { return 0, rest.ErrPermissionDenied } - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) + return r.CountAll(r.parseRestOptions(r.ctx, options...)) } -func (r *userRepository) Read(ctx context.Context, id string) (*model.User, error) { - usr := loggedUser(ctx) +func (r *userRepository) Read(id string) (any, error) { + usr := loggedUser(r.ctx) if !usr.IsAdmin && usr.ID != id { return nil, rest.ErrPermissionDenied } - return r.Get(ctx, id) + usr, err := r.Get(id) + if errors.Is(err, model.ErrNotFound) { + return nil, rest.ErrNotFound + } + return usr, err } -func (r *userRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.User, error) { - usr := loggedUser(ctx) +func (r *userRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + usr := loggedUser(r.ctx) if !usr.IsAdmin { return nil, rest.ErrPermissionDenied } - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) + return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *userRepository) Save(ctx context.Context, u *model.User) (string, error) { - usr := loggedUser(ctx) +func (r *userRepository) EntityName() string { + return "user" +} + +func (r *userRepository) NewInstance() any { + return &model.User{} +} + +func (r *userRepository) Save(entity any) (string, error) { + usr := loggedUser(r.ctx) if !usr.IsAdmin { return "", rest.ErrPermissionDenied } - if err := validateUsernameUnique(ctx, r, u); err != nil { + u := entity.(*model.User) + if err := validateUsernameUnique(r, u); err != nil { return "", err } - if err := validateScrobbleFilter(u); err != nil { - return "", err - } - err := r.Put(ctx, u) + err := r.Put(u) if err != nil { return "", err } return u.ID, err } -func (r *userRepository) Update(ctx context.Context, id string, entity model.User, _ ...string) error { - u := &entity +func (r *userRepository) Update(id string, entity any, _ ...string) error { + u := entity.(*model.User) u.ID = id - usr := loggedUser(ctx) + usr := loggedUser(r.ctx) if !usr.IsAdmin && usr.ID != u.ID { return rest.ErrPermissionDenied } @@ -288,19 +275,20 @@ func (r *userRepository) Update(ctx context.Context, id string, entity model.Use } // Decrypt the user's existing password before validating. This is required otherwise the existing password entered by the user will never match. - if err := r.decryptPassword(ctx, usr); err != nil { + if err := r.decryptPassword(usr); err != nil { return err } if err := validatePasswordChange(u, usr); err != nil { return err } - if err := validateUsernameUnique(ctx, r, u); err != nil { + if err := validateUsernameUnique(r, u); err != nil { return err } - if err := validateScrobbleFilter(u); err != nil { - return err + err := r.Put(u) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound } - return r.Put(ctx, u) + return err } func validatePasswordChange(newUser *model.User, logged *model.User) error { @@ -329,8 +317,8 @@ func validatePasswordChange(newUser *model.User, logged *model.User) error { return nil } -func validateUsernameUnique(ctx context.Context, r model.UserRepository, u *model.User) error { - usr, err := r.FindByUsername(ctx, u.UserName) +func validateUsernameUnique(r model.UserRepository, u *model.User) error { + usr, err := r.FindByUsername(u.UserName) if errors.Is(err, model.ErrNotFound) { return nil } @@ -343,45 +331,22 @@ func validateUsernameUnique(ctx context.Context, r model.UserRepository, u *mode return nil } -func validateScrobbleFilter(u *model.User) error { - u.ScrobbleFilter = strings.TrimSpace(u.ScrobbleFilter) - if u.ScrobbleFilter == "" { - return nil - } - var c criteria.Criteria - if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil { - return invalidScrobbleFilter() - } - // A filter is a per-track test, so a result-set size means nothing here. Reject it - // rather than silently ignoring part of a rule copied from a smart playlist. - if c.Limit > 0 || c.LimitPercent > 0 || c.Offset > 0 || c.RefreshDelay > 0 { - return invalidScrobbleFilter() - } - // Building the WHERE clause is what validates field names and operators - if _, err := newSmartPlaylistCriteria(c).where(); err != nil { - return invalidScrobbleFilter() - } - return nil -} - -func invalidScrobbleFilter() error { - return &rest.ValidationError{Errors: map[string]string{ - "scrobbleFilter": "resources.user.validation.invalidScrobbleFilter", - }} -} - -func (r *userRepository) Delete(ctx context.Context, ids ...string) error { - usr := loggedUser(ctx) +func (r *userRepository) Delete(id string) error { + usr := loggedUser(r.ctx) if !usr.IsAdmin { return rest.ErrPermissionDenied } - for _, id := range ids { - if err := r.deleteByID(ctx, id); err != nil { - return err - } - if err := cleanupPluginUserReferences(r.db, id); err != nil { - log.Error(ctx, "Failed to cleanup plugin user references", "userID", id, err) - } + err := r.delete(Eq{"id": id}) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + if err != nil { + return err + } + + // Clean up orphaned plugin references for the deleted user + if err := cleanupPluginUserReferences(r.db, id); err != nil { + log.Error(r.ctx, "Failed to cleanup plugin user references", "userID", id, err) } return nil } @@ -391,7 +356,7 @@ func keyTo32Bytes(input string) []byte { return data[0:] } -func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { +func (r *userRepository) initPasswordEncryptionKey() error { encKey = keyTo32Bytes(consts.DefaultEncryptionKey) if conf.Server.PasswordEncryptionKey == "" { return nil @@ -400,8 +365,8 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { key := keyTo32Bytes(conf.Server.PasswordEncryptionKey) keySum := fmt.Sprintf("%x", sha256.Sum256(key)) - props := NewPropertyRepository(r.db) - savedKeySum, err := props.Get(ctx, consts.PasswordsEncryptedKey) + props := NewPropertyRepository(r.ctx, r.db) + savedKeySum, err := props.Get(consts.PasswordsEncryptedKey) // If passwords are already encrypted if err == nil { @@ -415,24 +380,24 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { // if not, try to re-encrypt all current passwords with new encryption key, // assuming they were encrypted with the DefaultEncryptionKey - sql := r.newSelect(ctx).Columns("id", "user_name", "password") + sql := r.newSelect().Columns("id", "user_name", "password") users := model.Users{} - err = r.queryAll(ctx, sql, &users) + err = r.queryAll(sql, &users) if err != nil { log.Error("Could not encrypt all passwords", err) return err } log.Warn("New PasswordEncryptionKey set. Encrypting all passwords", "numUsers", len(users)) - if err = r.decryptAllPasswords(ctx, users); err != nil { + if err = r.decryptAllPasswords(users); err != nil { return err } encKey = key for i := range users { u := users[i] u.NewPassword = u.Password - if err := r.encryptPassword(ctx, &u); err == nil { + if err := r.encryptPassword(&u); err == nil { upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID}) - _, err = r.executeSQL(ctx, upd) + _, err = r.executeSQL(upd) if err != nil { log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err) } else { @@ -441,7 +406,7 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { } } - err = props.Put(ctx, consts.PasswordsEncryptedKey, keySum) + err = props.Put(consts.PasswordsEncryptedKey, keySum) if err != nil { log.Error("Could not flag passwords as encrypted. It will cause login errors", err) return err @@ -450,10 +415,10 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { } // encrypts u.NewPassword -func (r *userRepository) encryptPassword(ctx context.Context, u *model.User) error { - encPassword, err := utils.Encrypt(ctx, encKey, u.NewPassword) +func (r *userRepository) encryptPassword(u *model.User) error { + encPassword, err := utils.Encrypt(r.ctx, encKey, u.NewPassword) if err != nil { - log.Error(ctx, "Error encrypting user's password", "user", u.UserName, err) + log.Error(r.ctx, "Error encrypting user's password", "user", u.UserName, err) return err } u.NewPassword = encPassword @@ -461,19 +426,19 @@ func (r *userRepository) encryptPassword(ctx context.Context, u *model.User) err } // decrypts u.Password -func (r *userRepository) decryptPassword(ctx context.Context, u *model.User) error { - plaintext, err := utils.Decrypt(ctx, encKey, u.Password) +func (r *userRepository) decryptPassword(u *model.User) error { + plaintext, err := utils.Decrypt(r.ctx, encKey, u.Password) if err != nil { - log.Error(ctx, "Error decrypting user's password", "user", u.UserName, err) + log.Error(r.ctx, "Error decrypting user's password", "user", u.UserName, err) return err } u.Password = plaintext return nil } -func (r *userRepository) decryptAllPasswords(ctx context.Context, users model.Users) error { +func (r *userRepository) decryptAllPasswords(users model.Users) error { for i := range users { - if err := r.decryptPassword(ctx, &users[i]); err != nil { + if err := r.decryptPassword(&users[i]); err != nil { return err } } @@ -482,7 +447,7 @@ func (r *userRepository) decryptAllPasswords(ctx context.Context, users model.Us // Library association methods -func (r *userRepository) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) { +func (r *userRepository) GetUserLibraries(userID string) (model.Libraries, error) { sel := Select("l.*"). From("library l"). Join("user_library ul ON l.id = ul.library_id"). @@ -490,14 +455,14 @@ func (r *userRepository) GetUserLibraries(ctx context.Context, userID string) (m OrderBy("l.name") var res model.Libraries - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) return res, err } -func (r *userRepository) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error { +func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error { // Remove existing associations delSql := Delete("user_library").Where(Eq{"user_id": userID}) - if _, err := r.executeSQL(ctx, delSql); err != nil { + if _, err := r.executeSQL(delSql); err != nil { return err } @@ -507,12 +472,12 @@ func (r *userRepository) SetUserLibraries(ctx context.Context, userID string, li for _, libID := range libraryIDs { insert = insert.Values(userID, libID) } - _, err := r.executeSQL(ctx, insert) + _, err := r.executeSQL(insert) return err } return nil } var _ model.UserRepository = (*userRepository)(nil) -var _ rest.Repository[model.User] = (*userRepository)(nil) -var _ rest.Persistable[model.User] = (*userRepository)(nil) +var _ rest.Repository = (*userRepository)(nil) +var _ rest.Persistable = (*userRepository)(nil) diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 0e776fc3a..8abbf76a9 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "slices" - "sync" "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -12,20 +11,16 @@ 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/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("UserRepository", func() { var repo model.UserRepository - var ctx context.Context BeforeEach(func() { - ctx = log.NewContext(GinkgoT().Context()) - repo = NewUserRepository(GetDBXBuilder()) + repo = NewUserRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()) }) Describe("Put/Get/FindByUsername", func() { @@ -38,20 +33,20 @@ var _ = Describe("UserRepository", func() { IsAdmin: true, } It("saves the user to the DB", func() { - Expect(repo.Put(ctx, &usr)).To(BeNil()) + Expect(repo.Put(&usr)).To(BeNil()) }) It("returns the newly created user", func() { - actual, err := repo.Get(ctx, "123") + actual, err := repo.Get("123") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) }) It("find the user by case-insensitive username", func() { - actual, err := repo.FindByUsername(ctx, "aDmIn") + actual, err := repo.FindByUsername("aDmIn") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) }) It("find the user by username and decrypts the password", func() { - actual, err := repo.FindByUsernameWithPassword(ctx, "aDmIn") + actual, err := repo.FindByUsernameWithPassword("aDmIn") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) Expect(actual.Password).To(Equal("wordpass")) @@ -59,41 +54,21 @@ var _ = Describe("UserRepository", func() { It("updates the name and keep the same password", func() { usr.Name = "Jane Doe" usr.NewPassword = "" - Expect(repo.Put(ctx, &usr)).To(BeNil()) + Expect(repo.Put(&usr)).To(BeNil()) - actual, err := repo.FindByUsernameWithPassword(ctx, "admin") + actual, err := repo.FindByUsernameWithPassword("admin") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Jane Doe")) Expect(actual.Password).To(Equal("wordpass")) }) It("updates password if specified", func() { usr.NewPassword = "newpass" - Expect(repo.Put(ctx, &usr)).To(BeNil()) + Expect(repo.Put(&usr)).To(BeNil()) - actual, err := repo.FindByUsernameWithPassword(ctx, "admin") + actual, err := repo.FindByUsernameWithPassword("admin") Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) - It("persists and reads back the scrobble filter", func() { - usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User", - ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - saved, err := repo.Get(ctx, "u-filter") - Expect(err).ToNot(HaveOccurred()) - Expect(saved.ScrobbleFilter).To(Equal(`{"all":[{"contains":{"title":"????"}}]}`)) - }) - It("reads back a user row inserted without scrobble_filter", func() { - // Guards the column's NOT NULL DEFAULT '': rows predating the migration must stay scannable - _, err := GetDBXBuilder().NewQuery( - "insert into user (id, user_name, name, email, password, created_at, updated_at) " + - "values ('u-rawsql', 'u-rawsql', 'Raw', '', '', datetime('now'), datetime('now'))").Execute() - Expect(err).ToNot(HaveOccurred()) - - saved, err := repo.Get(ctx, "u-rawsql") - Expect(err).ToNot(HaveOccurred()) - Expect(saved.ScrobbleFilter).To(Equal("")) - }) }) Describe("validatePasswordChange", func() { @@ -232,74 +207,23 @@ var _ = Describe("UserRepository", func() { }) }) - Describe("Delete", func() { - It("returns not found for a missing user", func() { - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - adminRepo := NewUserRepository(GetDBXBuilder()).(*userRepository) - Expect(adminRepo.Delete(adminCtx, "does-not-exist")).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("ReadAll name filter", func() { - var adminRepo model.UserRepository - var adminCtx context.Context - - BeforeEach(func() { - adminCtx = request.WithUser(ctx, model.User{ID: "admin-id", UserName: "admin", IsAdmin: true}) - adminRepo = NewUserRepository(GetDBXBuilder()) - - 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.Put(adminCtx, &u)).To(Succeed()) - } - }) - - AfterEach(func() { - _ = adminRepo.Delete(adminCtx, "filter-alice", "filter-bob") - }) - - It("matches users whose name starts with the given prefix", func() { - users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Alice"}}) - Expect(err).ToNot(HaveOccurred()) - - 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() { - users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Filter"}}) - Expect(err).ToNot(HaveOccurred()) - - 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 BeforeEach(func() { existingUser = &model.User{ID: "1", UserName: "johndoe"} repo = tests.CreateMockUserRepo() - err := repo.Put(ctx, existingUser) + err := repo.Put(existingUser) Expect(err).ToNot(HaveOccurred()) }) It("allows unique usernames", func() { var newUser = &model.User{ID: "2", UserName: "unique_username"} - err := validateUsernameUnique(ctx, repo, newUser) + err := validateUsernameUnique(repo, newUser) Expect(err).ToNot(HaveOccurred()) }) It("returns ValidationError if username already exists", func() { var newUser = &model.User{ID: "2", UserName: "johndoe"} - err := validateUsernameUnique(ctx, repo, newUser) + err := validateUsernameUnique(repo, newUser) var verr *rest.ValidationError isValidationError := errors.As(err, &verr) @@ -310,7 +234,7 @@ var _ = Describe("UserRepository", func() { repo.Error = errors.New("fake error") var newUser = &model.User{ID: "2", UserName: "newuser"} - err := validateUsernameUnique(ctx, repo, newUser) + err := validateUsernameUnique(repo, newUser) Expect(err).To(MatchError("fake error")) }) }) @@ -329,39 +253,39 @@ var _ = Describe("UserRepository", func() { NewPassword: "password", IsAdmin: false, } - Expect(repo.Put(ctx, &testUser)).To(BeNil()) + Expect(repo.Put(&testUser)).To(BeNil()) userID = testUser.ID library1 = model.Library{ID: 0, Name: "Library 500", Path: "/path/500"} library2 = model.Library{ID: 0, Name: "Library 501", Path: "/path/501"} // Create test libraries - libRepo := NewLibraryRepository(GetDBXBuilder()) - Expect(libRepo.Put(ctx, &library1)).To(BeNil()) - Expect(libRepo.Put(ctx, &library2)).To(BeNil()) + libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + Expect(libRepo.Put(&library1)).To(BeNil()) + Expect(libRepo.Put(&library2)).To(BeNil()) }) AfterEach(func() { // Clean up user-library associations to ensure test isolation - _ = repo.SetUserLibraries(ctx, userID, []int{}) + _ = repo.SetUserLibraries(userID, []int{}) // Clean up test libraries to ensure isolation between test groups - libRepo := NewLibraryRepository(GetDBXBuilder()) - _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) + libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}}) }) Describe("GetUserLibraries", func() { It("returns empty list when user has no library associations", func() { - libraries, err := repo.GetUserLibraries(ctx, "non-existent-user") + libraries, err := repo.GetUserLibraries("non-existent-user") Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(0)) }) It("returns user's associated libraries", func() { - err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) + err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) Expect(err).ToNot(HaveOccurred()) - libraries, err := repo.GetUserLibraries(ctx, userID) + libraries, err := repo.GetUserLibraries(userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(2)) @@ -373,24 +297,24 @@ var _ = Describe("UserRepository", func() { Describe("SetUserLibraries", func() { It("sets user's library associations", func() { libraryIDs := []int{library1.ID, library2.ID} - err := repo.SetUserLibraries(ctx, userID, libraryIDs) + err := repo.SetUserLibraries(userID, libraryIDs) Expect(err).ToNot(HaveOccurred()) - libraries, err := repo.GetUserLibraries(ctx, userID) + libraries, err := repo.GetUserLibraries(userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(2)) }) It("replaces existing associations", func() { // Set initial associations - err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) + err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) Expect(err).ToNot(HaveOccurred()) // Replace with just one library - err = repo.SetUserLibraries(ctx, userID, []int{library1.ID}) + err = repo.SetUserLibraries(userID, []int{library1.ID}) Expect(err).ToNot(HaveOccurred()) - libraries, err := repo.GetUserLibraries(ctx, userID) + libraries, err := repo.GetUserLibraries(userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(1)) Expect(libraries[0].ID).To(Equal(library1.ID)) @@ -398,14 +322,14 @@ var _ = Describe("UserRepository", func() { It("removes all associations when passed empty slice", func() { // Set initial associations - err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) + err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) Expect(err).ToNot(HaveOccurred()) // Remove all - err = repo.SetUserLibraries(ctx, userID, []int{}) + err = repo.SetUserLibraries(userID, []int{}) Expect(err).ToNot(HaveOccurred()) - libraries, err := repo.GetUserLibraries(ctx, userID) + libraries, err := repo.GetUserLibraries(userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(0)) }) @@ -421,10 +345,10 @@ var _ = Describe("UserRepository", func() { ) BeforeEach(func() { - libRepo = NewLibraryRepository(GetDBXBuilder()) + libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) // Count initial libraries - existingLibs, err := libRepo.GetAll(ctx) + existingLibs, err := libRepo.GetAll() Expect(err).ToNot(HaveOccurred()) initialLibCount = len(existingLibs) @@ -432,16 +356,16 @@ var _ = Describe("UserRepository", func() { library2 = model.Library{ID: 0, Name: "Admin Test Library 2", Path: "/admin/test/path2"} // Create test libraries - Expect(libRepo.Put(ctx, &library1)).To(BeNil()) - Expect(libRepo.Put(ctx, &library2)).To(BeNil()) + Expect(libRepo.Put(&library1)).To(BeNil()) + Expect(libRepo.Put(&library2)).To(BeNil()) }) AfterEach(func() { // Clean up test libraries and their associations - _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) + _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}}) // Clean up user-library associations for these test libraries - _, _ = repo.(*userRepository).executeSQL(ctx, squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) + _, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) }) It("automatically assigns all libraries to admin users when created", func() { @@ -454,11 +378,11 @@ var _ = Describe("UserRepository", func() { IsAdmin: true, } - err := repo.Put(ctx, &adminUser) + err := repo.Put(&adminUser) Expect(err).ToNot(HaveOccurred()) // Admin should automatically have access to all libraries (including existing ones) - libraries, err := repo.GetUserLibraries(ctx, adminUser.ID) + libraries, err := repo.GetUserLibraries(adminUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries @@ -480,20 +404,20 @@ var _ = Describe("UserRepository", func() { IsAdmin: false, } - err := repo.Put(ctx, ®ularUser) + err := repo.Put(®ularUser) Expect(err).ToNot(HaveOccurred()) // Give them access to just one library - err = repo.SetUserLibraries(ctx, regularUser.ID, []int{library1.ID}) + err = repo.SetUserLibraries(regularUser.ID, []int{library1.ID}) Expect(err).ToNot(HaveOccurred()) // Promote to admin regularUser.IsAdmin = true - err = repo.Put(ctx, ®ularUser) + err = repo.Put(®ularUser) Expect(err).ToNot(HaveOccurred()) // Should now have access to all libraries (including existing ones) - libraries, err := repo.GetUserLibraries(ctx, regularUser.ID) + libraries, err := repo.GetUserLibraries(regularUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries @@ -515,11 +439,11 @@ var _ = Describe("UserRepository", func() { IsAdmin: false, } - err := repo.Put(ctx, ®ularUser) + err := repo.Put(®ularUser) Expect(err).ToNot(HaveOccurred()) // Regular user should be assigned to default libraries (library ID 1 from migration) - libraries, err := repo.GetUserLibraries(ctx, regularUser.ID) + libraries, err := repo.GetUserLibraries(regularUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(1)) Expect(libraries[0].ID).To(Equal(1)) @@ -536,13 +460,13 @@ var _ = Describe("UserRepository", func() { ) BeforeEach(func() { - libRepo = NewLibraryRepository(GetDBXBuilder()) + libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) library1 = model.Library{ID: 0, Name: "Field Test Library 1", Path: "/field/test/path1"} library2 = model.Library{ID: 0, Name: "Field Test Library 2", Path: "/field/test/path2"} // Create test libraries - Expect(libRepo.Put(ctx, &library1)).To(BeNil()) - Expect(libRepo.Put(ctx, &library2)).To(BeNil()) + Expect(libRepo.Put(&library1)).To(BeNil()) + Expect(libRepo.Put(&library2)).To(BeNil()) // Create test user testUser = model.User{ @@ -553,23 +477,23 @@ var _ = Describe("UserRepository", func() { NewPassword: "password", IsAdmin: false, } - Expect(repo.Put(ctx, &testUser)).To(BeNil()) + Expect(repo.Put(&testUser)).To(BeNil()) // Assign libraries to user - Expect(repo.SetUserLibraries(ctx, testUser.ID, []int{library1.ID, library2.ID})).To(BeNil()) + Expect(repo.SetUserLibraries(testUser.ID, []int{library1.ID, library2.ID})).To(BeNil()) }) AfterEach(func() { // Clean up test libraries and their associations - _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) - _ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": testUser.ID}) + _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}}) + _ = repo.(*userRepository).delete(squirrel.Eq{"id": testUser.ID}) // Clean up user-library associations for these test libraries - _, _ = repo.(*userRepository).executeSQL(ctx, squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) + _, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) }) It("populates Libraries field when getting a single user", func() { - user, err := repo.Get(ctx, testUser.ID) + user, err := repo.Get(testUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).To(HaveLen(2)) @@ -590,7 +514,7 @@ var _ = Describe("UserRepository", func() { }) It("populates Libraries field when getting all users", func() { - users, err := repo.(*userRepository).GetAll(ctx) + users, err := repo.(*userRepository).GetAll() Expect(err).ToNot(HaveOccurred()) // Find our test user in the results @@ -606,7 +530,7 @@ var _ = Describe("UserRepository", func() { }) It("populates Libraries field when finding user by username", func() { - user, err := repo.FindByUsername(ctx, testUser.UserName) + user, err := repo.FindByUsername(testUser.UserName) Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).To(HaveLen(2)) @@ -624,10 +548,10 @@ var _ = Describe("UserRepository", func() { NewPassword: "password", IsAdmin: false, } - Expect(repo.Put(ctx, &userWithoutLibs)).To(BeNil()) - defer func() { _ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": userWithoutLibs.ID}) }() + Expect(repo.Put(&userWithoutLibs)).To(BeNil()) + defer func() { _ = repo.(*userRepository).delete(squirrel.Eq{"id": userWithoutLibs.ID}) }() - user, err := repo.Get(ctx, userWithoutLibs.ID) + user, err := repo.Get(userWithoutLibs.ID) Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).ToNot(BeNil()) // Regular users should be assigned to default libraries (library ID 1 from migration) @@ -636,214 +560,14 @@ var _ = Describe("UserRepository", func() { }) }) - Describe("validateScrobbleFilter", func() { - It("accepts an empty filter", func() { - u := &model.User{} - Expect(validateScrobbleFilter(u)).To(Succeed()) - }) - It("trims a whitespace-only filter to empty", func() { - u := &model.User{ScrobbleFilter: " "} - Expect(validateScrobbleFilter(u)).To(Succeed()) - Expect(u.ScrobbleFilter).To(Equal("")) - }) - It("accepts valid criteria JSON", func() { - u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}]}`} - Expect(validateScrobbleFilter(u)).To(Succeed()) - }) - It("rejects malformed JSON", func() { - u := &model.User{ScrobbleFilter: `{not json`} - var vErr *rest.ValidationError - err := validateScrobbleFilter(u) - Expect(errors.As(err, &vErr)).To(BeTrue()) - Expect(vErr.Errors).To(HaveKey("scrobbleFilter")) - }) - It("rejects criteria without rules", func() { - u := &model.User{ScrobbleFilter: `{"sort":"title"}`} - Expect(validateScrobbleFilter(u)).ToNot(Succeed()) - }) - It("rejects selection options that mean nothing for a single track", func() { - for _, f := range []string{ - `{"all":[{"lt":{"rating":4}}],"limit":100}`, - `{"all":[{"lt":{"rating":4}}],"limitPercent":10}`, - `{"all":[{"lt":{"rating":4}}],"offset":5}`, - `{"all":[{"lt":{"rating":4}}],"refreshDelay":"1h"}`, - } { - u := &model.User{ScrobbleFilter: f} - Expect(validateScrobbleFilter(u)).ToNot(Succeed(), f) - } - }) - It("accepts a sort, which cannot change a single-track match", func() { - u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}],"sort":"title"}`} - Expect(validateScrobbleFilter(u)).To(Succeed()) - }) - It("rejects unknown fields", func() { - u := &model.User{ScrobbleFilter: `{"all":[{"is":{"bogusfield":1}}]}`} - Expect(validateScrobbleFilter(u)).ToNot(Succeed()) - }) - }) - Describe("filters", func() { It("qualifies id filter with table name", func() { r := repo.(*userRepository) - qo := r.parseRestOptions(ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}}) - sel := r.selectUserWithLibraries(ctx, qo) + qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}}) + sel := r.selectUserWithLibraries(qo) query, _, err := r.toSQL(sel) Expect(err).NotTo(HaveOccurred()) Expect(query).To(ContainSubstring("user.id = {:p0}")) }) }) - - Describe("token epoch", func() { - var repo model.UserRepository - var usr model.User - - newUser := func() model.User { - uid := id.NewRandom() - // user_name is unique; suffix it so each It gets its own row in the shared suite DB. - return model.User{ID: uid, UserName: "epoch-user-" + uid, Name: "Epoch", NewPassword: "hunter2"} - } - - BeforeEach(func() { - ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true}) - repo = NewUserRepository(GetDBXBuilder()) - usr = newUser() - Expect(repo.Put(ctx, &usr)).To(Succeed()) - }) - - It("starts at zero for a new user", func() { - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(0)) - }) - - It("increments once per password change", func() { - usr.NewPassword = "second" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(1)) - - usr.NewPassword = "third" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - got, err = repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(2)) - }) - - It("leaves the epoch alone when the password is untouched", func() { - usr.NewPassword = "" - usr.Name = "Renamed" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(0)) - Expect(got.Name).To(Equal("Renamed")) - }) - - It("never signals the same epoch to two concurrent password changes", func() { - // Each writer's epoch must be the one its own UPDATE produced. - const callers = 4 - var mu sync.Mutex - var signalled []int - var wg sync.WaitGroup - for range callers { - wg.Go(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: usr.ID}) - ctx = request.WithTokenEpochHolder(ctx) - own := NewUserRepository(GetDBXBuilder()) - - u := usr - u.NewPassword = "concurrent" - if err := own.Put(ctx, &u); err != nil { - return // the shared in-memory test DB can raise SQLITE_LOCKED - } - epoch, ok := request.TokenEpochFrom(ctx) - if !ok { - return - } - mu.Lock() - defer mu.Unlock() - signalled = append(signalled, epoch) - }) - } - wg.Wait() - - Expect(signalled).To(HaveLen(len(slice.Unique(signalled))), - "an epoch was signalled to more than one writer: %v", signalled) - }) - }) - - Describe("Put and the token epoch", func() { - newRepo := func(actingUserID string) (context.Context, model.UserRepository) { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true}) - ctx = request.WithTokenEpochHolder(ctx) - return ctx, NewUserRepository(GetDBXBuilder()) - } - - It("does not bump when creating a user", func() { - ctx, repo := newRepo("admin") - usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(0)) - }) - - It("bumps when the password changes", func() { - ctx, repo := newRepo("admin") - usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - usr.NewPassword = "pw2" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(1)) - }) - - It("does not bump on an edit that leaves the password alone", func() { - ctx, repo := newRepo("admin") - usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - usr.NewPassword = "" - usr.Name = "New Display Name" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(0)) - }) - - It("signals the new epoch when a user changes their own password", func() { - userID := id.NewRandom() - ctx, repo := newRepo(userID) - usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - usr.NewPassword = "pw2" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - epoch, ok := request.TokenEpochFrom(ctx) - Expect(ok).To(BeTrue()) - Expect(epoch).To(Equal(1)) - }) - - It("does not signal when an admin changes someone else's password", func() { - ctx, repo := newRepo("some-admin") - usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - usr.NewPassword = "pw2" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - _, ok := request.TokenEpochFrom(ctx) - Expect(ok).To(BeFalse()) - }) - }) }) diff --git a/plugins/README.md b/plugins/README.md index 7dca3a5f2..048cf549d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -30,18 +30,14 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [Scheduler](#scheduler) - [Cache](#cache) - [KVStore](#kvstore) - - [Storage](#storage) - [Task](#task) - [WebSocket](#websocket) - [Library](#library) - - [Matcher](#matcher) - [Artwork](#artwork) - [SubsonicAPI](#subsonicapi) - [Config](#config) - [Users](#users) - - [ScrobbleRetriever](#scrobbleretriever) - [Configuration](#configuration) -- [Command Line Interface](#command-line-interface) - [Building Plugins](#building-plugins) - [Examples](#examples) - [Security](#security) @@ -140,7 +136,7 @@ Every plugin must include a `manifest.json` file. Example: **Required fields:** `name`, `author`, `version` -**Optional fields:** `description`, `website`, `config`, `permissions` +**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental` #### Config Definition @@ -164,6 +160,24 @@ The `config` field defines the plugin's configuration schema using [JSON Schema } ``` +#### Experimental Features + +Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported: + +- **`threads`** – Enables WebAssembly threads support (for plugins compiled with multi-threading) + +```json +{ + "experimental": { + "threads": { + "reason": "Required for concurrent audio processing" + } + } +} +``` + +> **Note:** Experimental features may have compatibility or performance implications. Use only when necessary. + --- ## Capabilities @@ -174,14 +188,6 @@ Capabilities define what your plugin can do. They're automatically detected base Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports. -> **Returning "not found".** When you have no data for an item, return an empty response and no -> error. In the Go PDK that is `return nil, nil`. Navidrome reads it as a definitive "not found" -> and stops asking. -> -> Return an error only when the plugin itself failed, such as an unreachable API or a broken host -> call. Navidrome retries failed calls with backoff. A plugin that errors on "no data" makes -> Navidrome retry every item it has no data for. - | Function | Input | Output | Description | |-----------------------------------|----------------------------|----------------------------------|--------------------------| | `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | @@ -236,14 +242,13 @@ func ndGetArtistBiography() int32 { ### Scrobbler -Integrates with external scrobbling services. All four methods are **required**. +Integrates with external scrobbling services. All three methods are **required**. -| Function | Input | Output | Description | -|---------------------------------|--------------|--------|------------------------------| -| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | -| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | -| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | -| `nd_scrobbler_playback_report` | See below | (none) | Send playback state report | +| Function | Input | Output | Description | +|------------------------------|-----------------------|--------|-----------------------------| +| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | +| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | +| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | > **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. @@ -281,25 +286,6 @@ Integrates with external scrobbling services. All four methods are **required**. } ``` -**PlaybackReport Input:** - -Same `username` and `track` fields, plus playback state details: - -```json -{ - "username": "john", - "track": { ... }, - "state": "playing", - "positionMs": 45000, - "playbackRate": 1.0, - "playerId": "player-id", - "playerName": "My Client", - "timestamp": 1703270400 -} -``` - -`state` is one of `starting`, `playing`, `paused`, `stopped`, or `expired`. - **Error Handling:** On success, return `0`. On failure, use `pdk.SetError()` with one of these error types: @@ -339,7 +325,7 @@ Each match contains a `song` reference and a `similarity` score (float64, 0.0– ### TaskWorker -Processes tasks from a queue. **Required** if your plugin uses the [Task](#task) host service: declaring the `taskqueue` permission without exporting this function fails the plugin load. +Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work. | Function | Input | Output | Description | |---------------------|---------------------------------------------|---------|----------------------| @@ -359,7 +345,7 @@ Useful for initializing connections, scheduling recurring tasks, etc. Errors are ### SchedulerCallback -Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service: declaring the `scheduler` permission without exporting this function fails the plugin load. +Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service. | Function | Input | Output | Description | |---------------------------|----------------------------------------------|--------|-----------------------------| @@ -376,8 +362,6 @@ Receives WebSocket events. Export any subset of these to handle events from the | `nd_websocket_on_error` | `{connectionId, error}` | Connection error | | `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | -Each callback invocation is subject to a 30-second timeout. - --- ## Host Services @@ -401,7 +385,7 @@ import "github.com/navidrome/navidrome/plugins/pdk/go/host" ### HTTP -Make HTTP requests to external services, with timeouts, redirect control, and protection against reaching private network addresses. This is the only supported way to make HTTP requests: Extism's built-in HTTP is disabled (`pdk.NewHTTPRequest` in Go, `http::request` in Rust, `extism.Http.request` in Python, `Http.request` in JS). +Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control. **Manifest permission:** @@ -416,16 +400,12 @@ Make HTTP requests to external services, with timeouts, redirect control, and pr } ``` -**Private addresses:** the check runs on the resolved IP when connecting. A named host entry (`api.example.com`, `*.example.com`) never authorizes a loopback, private or link-local address on its own, even if its DNS points there. To reach a service on the local network, also list its IP or a CIDR (`192.168.1.10`, `10.0.0.0/8`), or use `"*"` when the user configures the address. Without `requiredHosts`, only public addresses are allowed. - **Host functions:** | Function | Parameters | Returns | |-------------|----------------------------------------------------------|----------------------------------| | `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` | -**Limits:** Requests time out after 10 seconds by default (override per request with `timeoutMs`). Redirects are followed up to 5 times, re-checking the allowed hosts on every hop. Response bodies are capped at 10MB. - **Usage:** ```go @@ -586,52 +566,6 @@ usage, err := host.KVStoreGetStorageUsed() fmt.Printf("Using %d bytes\n", usage) ``` -### Storage - -A private read-write directory, mounted into the sandbox at `/storage` and backed by `${DataFolder}/plugins/${pluginID}/storage`. Survives server restarts. Use it for data that doesn't fit a key-value store: caches, downloaded files, generated indexes. - -**Manifest permission:** - -```json -{ - "permissions": { - "storage": { - "reason": "Cache generated playlists between restarts" - } - } -} -``` - -**Host functions:** - -| Function | Parameters | Description | -|--------------------------|------------|------------------------------------| -| `storage_getstoragepath` | – | Get the guest path of the mount | - -**Usage:** - -Normal WASI filesystem calls work inside the mount, so use the `os` package directly: - -```go -import ( - "os" - "path/filepath" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" -) - -// The path never changes, so read it once instead of per operation -var storageDir = host.StorageGetStoragePath() // "/storage" - -err := os.WriteFile(filepath.Join(storageDir, "cache.json"), data, 0600) -content, err := os.ReadFile(filepath.Join(storageDir, "cache.json")) -entries, err := os.ReadDir(storageDir) -``` - -> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks that already exist in the directory are still followed, so anything linked in from elsewhere remains reachable. - -> **Note:** There is no size limit, unlike [KVStore](#kvstore). The directory is not deleted when a plugin is uninstalled. - ### Task Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function. @@ -651,21 +585,13 @@ Background task queue with retry support. Plugins enqueue tasks and process them **Host functions:** -| Function | Parameters | Description | -|---------------------|---------------------------------------------------------------|----------------------------| -| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, delayMs, retentionMs` | Create a named task queue | -| `task_enqueue` | `queueName, payload` | Add a task to the queue | -| `task_get` | `taskID` | Get task status and result | -| `task_cancel` | `taskID` | Cancel a pending task | -| `task_clearqueue` | `queueName` | Remove all tasks from queue| - -Tasks are persisted to SQLite, so pending tasks survive server restarts. Queue behavior: - -- `concurrency` – Parallel workers (default 1), capped by the manifest's `maxConcurrency` -- `maxRetries` – Retries for a failed task (default 0); `backoffMs` (default 1000) doubles on each retry -- `delayMs` – Minimum delay between consecutive task starts, useful for rate limiting (default 0) -- `retentionMs` – How long finished tasks are kept (default 1 hour, min 1 minute, max 1 week) -- Payloads are capped at 1MB +| Function | Parameters | Description | +|---------------------|---------------------------------------------------|----------------------------| +| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue | +| `task_enqueue` | `queueName, payload` | Add a task to the queue | +| `task_get` | `taskID` | Get task status and result | +| `task_cancel` | `taskID` | Cancel a pending task | +| `task_clearqueue` | `queueName` | Remove all tasks from queue| **Usage:** @@ -702,8 +628,6 @@ Establish persistent WebSocket connections to external services. Your plugin mus } ``` -`requiredHosts` is mandatory here: leave it out and every connection is blocked. Unlike HTTP, there is no fallback to public addresses. Entries follow the same [private address rules](#http). - **Host functions:** | Function | Parameters | Description | @@ -738,7 +662,7 @@ Access music library metadata and optionally read files from library directories } ``` -- `filesystem` – Set to `true` to enable access to library directories, read-only unless an administrator grants write access (default: `false`) +- `filesystem` – Set to `true` to enable read-only access to library directories (default: `false`) **Host functions:** @@ -777,7 +701,7 @@ content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3") entries, err := os.ReadDir("/libraries/1/Artist") ``` -> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks already present in the library are still followed, so folders linked in from elsewhere work as expected. Access is read-only unless an administrator grants the plugin write access (`navidrome plugin edit --write-access`). +> **Security:** Filesystem access is read-only and restricted to configured library paths only. **Usage:** @@ -793,45 +717,6 @@ for _, lib := range libraries { } ``` -### Matcher - -Match externally-obtained songs (e.g. results from a recommendation or similarity API) to tracks in the local library, reusing Navidrome's matching algorithm (ID > MBID > ISRC > fuzzy title). - -**Manifest permission:** - -```json -{ - "permissions": { - "matcher": { - "reason": "Resolve external recommendations to library tracks" - }, - "library": { - "reason": "Required by the matcher permission" - } - } -} -``` - -> **Important:** The `matcher` permission requires the `library` permission. - -**Host functions:** - -| Function | Parameters | Returns | -|----------------------|---------------|-------------------------| -| `matcher_matchsongs` | `songs, opts` | Array of matched tracks | - -The result has one entry per input song, in the same order; the entry for a song with no match is empty. Results are limited to the libraries the plugin (and the scoped user, if any) can access. Set `opts.username` to run the match as a specific user: their favorites and ratings inform tiebreaking, and the returned tracks carry their annotations. User scoping additionally requires the [`users`](#users) permission, with users assigned to the plugin. - -**Usage:** - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/types" - -matches, err := host.MatcherMatchSongs([]types.SongRef{ - {Name: "Song Title", Artists: []types.ArtistRef{{Name: "Artist Name"}}}, -}, host.MatchOptions{}) -``` - ### Artwork Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists). @@ -982,82 +867,6 @@ for _, user := range users { admins, err := host.UsersGetAdmins() ``` -### ScrobbleRetriever - -Retrieve the scrobble history of users the plugin has been granted access to. Each scrobble carries only the media file ID and the submission time; use the Matcher host service to resolve them to track metadata. - -**Manifest permission:** - -```json -{ - "permissions": { - "scrobbleRetriever": { - "reason": "Sync scrobble history to an external service" - }, - "users": { - "reason": "Access user information for scrobble retrieval" - } - } -} -``` - -> **Important:** The `scrobbleRetriever` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI. - -**Host functions:** - -| Function | Parameters | Returns | -|---------------------------------------|-----------------------|----------------------------------| -| `scrobbleretriever_getfirsttimestamp` | `username` | Unix timestamp of oldest scrobble, or null | -| `scrobbleretriever_getlasttimestamp` | `username` | Unix timestamp of newest scrobble, or null | -| `scrobbleretriever_getscrobbles` | `username`, `options` | One page of scrobbles + options for the next page | -| `scrobbleretriever_getscrobblecount` | `username`, `options` | Number of scrobbles in the range | - -**ScrobbleOptions fields** (all optional): - -| Field | Type | Description | -|-----------------|---------|----------------------------------------------------------| -| `fromTimestamp` | int64 | Start of the range (inclusive). Default: first scrobble | -| `toTimestamp` | int64 | End of the range (inclusive). Default: last scrobble | -| `descending` | boolean | Newest first. Default: oldest first | -| `maxItems` | int | Page size, capped at 5000 (the default) | -| `offset` | int | Managed by the host for pagination. Never set it manually | - -**ScrobbleRef fields:** - -| Field | Type | Description | -|------------------|--------|------------------------------------------------| -| `id` | int64 | Scrobble ID, unique even for duplicate submissions | -| `mediaFileId` | string | The media file that was scrobbled | -| `submissionTime` | int64 | Unix timestamp of the submission | - -**Usage:** - -`GetScrobbles` returns one page plus the options to fetch the following page. Pass them back unchanged and repeat until they are nil: - -```go -opts := host.ScrobbleOptions{MaxItems: 500} -var all []host.ScrobbleRef -for { - page, next, err := host.ScrobbleRetrieverGetScrobbles("username", opts) - if err != nil { - return err - } - all = append(all, page...) - if next == nil { - break // no more scrobbles - } - opts = *next -} - -// Range boundaries and counts -first, err := host.ScrobbleRetrieverGetFirstTimestamp("username") // nil if no scrobbles -count, err := host.ScrobbleRetrieverGetScrobbleCount("username", host.ScrobbleCountOptions{ - FromTimestamp: first, -}) -``` - -> **Note:** The returned `next` options carry an adjusted `fromTimestamp`/`toTimestamp`, so keep a copy of your original options if you still need the range. - --- ## Configuration @@ -1093,29 +902,6 @@ For more advanced access (listing keys, integer values), use the [Config](#confi --- -## Command Line Interface - -Manage plugins from the command line with `navidrome plugin`: - -| Command | Description | -|--------------------------------------------------------|------------------------------------------------------------| -| `navidrome plugin list [-f table\|csv\|json]` | List installed plugins | -| `navidrome plugin info [-f text\|json]` | Show details for an installed plugin or a `.ndp` package | -| `navidrome plugin validate ` | Validate an installed plugin or a `.ndp` package manifest | -| `navidrome plugin enable ` | Enable a plugin | -| `navidrome plugin disable ` | Disable a plugin | -| `navidrome plugin edit ` | Update a plugin's config and/or permissions | -| `navidrome plugin rescan` | Re-discover plugins in the plugins folder | - -**`plugin edit` flags:** - -- `--config ` / `--config-file ` – Set the plugin configuration (`-` reads from stdin) -- `--users ` / `--all-users` – Usernames the plugin may access (comma-separated or JSON array), or all users -- `--libraries ` / `--all-libraries` – Library IDs the plugin may access (comma-separated or JSON array), or all libraries -- `--write-access` / `--no-write-access` – Allow or deny the plugin write access to libraries - ---- - ## Building Plugins ### Supported Languages @@ -1178,8 +964,6 @@ replace github.com/navidrome/navidrome => ../../.. | `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | | `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | | `host` | `plugins/pdk/go/host` | Host service SDK (all services) | -| `types` | `plugins/pdk/go/types` | Shared data types (tracks, artists, song refs) | -| `pdk` | `plugins/pdk/go/pdk` | Low-level helpers (wraps extism/go-pdk: config, logging, memory) | See the example plugins in [examples/](examples/) for complete usage patterns. @@ -1246,7 +1030,7 @@ extism-py plugin.wasm -o plugin.wasm *.py zip -j my-plugin.ndp manifest.json plugin.wasm ``` -There is no Python PDK, so call host services directly: import them from the `extism:host/user` namespace (e.g. `http_send`) with `@extism.import_fn`, and exchange JSON through Extism memory. Each function takes a JSON request and returns a JSON response with an `error` field on failure. For HTTP, send `{"request": {"method": "GET", "url": "..."}}` and read `result.statusCode` and `result.body` (base64). See [coverartarchive-py](examples/coverartarchive-py/) and [nowplaying-py](examples/nowplaying-py/). +**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) @@ -1280,11 +1064,12 @@ See [examples/](examples/) for complete working plugins: | [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | | [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | | [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | -| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | -| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle, SchedulerCallback | Scheduler, SubsonicAPI | Periodic now-playing logger | -| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle, SchedulerCallback | Library, Scheduler | Periodic library stats logging | -| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle, SchedulerCallback, WebSocketCallback | WebSocket, Scheduler | Real-time crypto prices demo | -| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler, SchedulerCallback, WebSocketCallback | HTTP, WebSocket, Cache, Scheduler, Artwork, Config | Discord integration | +| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive | +| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | +| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | +| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | +| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | +| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration | --- @@ -1293,11 +1078,11 @@ See [examples/](examples/) for complete working plugins: Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime: 1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket -2. **Limited File System** – Plugins cannot create symlinks inside a mount and `..` or absolute paths are rejected, though symlinks already present are followed. Library access requires the `library.filesystem` permission and is read-only unless an administrator grants write access; the `storage` permission grants a read-write directory private to the plugin +2. **Limited File System** – Read-only access to library directories, only when explicitly granted the `library.filesystem` permission 3. **No Network Listeners** – Plugins cannot bind ports 4. **Config Isolation** – Plugins only receive their own config section 5. **Memory Limits** – Controlled by the WebAssembly runtime -6. **User-Scoped Authorization** – Plugins with `subsonicapi`, `scrobbleRetriever`, or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration +6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration 7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed --- @@ -1312,7 +1097,7 @@ If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin c ### Enabling/Disabling Plugins -Plugins can be enabled/disabled via the Navidrome UI or the [`navidrome plugin` CLI](#command-line-interface). The plugin state is persisted in the database. +Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persisted in the database. ### Important Notes diff --git a/plugins/capabilities.go b/plugins/capabilities.go index b86475028..81e683b6b 100644 --- a/plugins/capabilities.go +++ b/plugins/capabilities.go @@ -1,11 +1,6 @@ package plugins -import ( - "regexp" - "slices" - - "github.com/navidrome/navidrome/core/agents" -) +import "slices" // Capability represents a plugin capability type. // Capabilities are detected by checking which functions a plugin exports. @@ -44,25 +39,3 @@ func detectCapabilities(plugin functionExistsChecker) []Capability { func hasCapability(capabilities []Capability, cap Capability) bool { return slices.Contains(capabilities, cap) } - -// retryLaterRe matches one capability's `(retry_later[:seconds])` token, which is -// all a plugin fault carries back across the WASM boundary. The capability is part of the -// pattern, so another capability's token in the same message cannot mask this one. The leading -// \b keeps a superstring like `useragent(retry_later)` from matching `agent`. -var ( - agentRetryLaterRe = retryLaterRe("agent") - scrobblerRetryLaterRe = retryLaterRe("scrobbler") -) - -func retryLaterRe(capability string) *regexp.Regexp { - return regexp.MustCompile(`\b` + capability + `\(retry_later(?::(\d+))?\)`) -} - -// parseRetryLater reports whether msg carries the capability's retry_later token, with its delay. -func parseRetryLater(re *regexp.Regexp, msg string) (*agents.RetryLaterError, bool) { - m := re.FindStringSubmatch(msg) - if m == nil { - return nil, false - } - return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(m[1])}, true -} diff --git a/plugins/capabilities/README.md b/plugins/capabilities/README.md index 2ad4a82da..fca3cbd31 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=../../capabilities -shared=../../types +cd plugins/cmd/ndpgen && go run . -schemas -input=./plugins/capabilities ``` ## Resources diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 7336124fc..04dd283dd 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -9,6 +9,20 @@ 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: @@ -110,31 +124,3 @@ 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 8fec7f5a8..407f21ec5 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -1,7 +1,5 @@ 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. @@ -9,9 +7,6 @@ import "github.com/navidrome/navidrome/plugins/types" // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. // -// To say "no data for this item", return a nil response and a nil error. Return an error only when -// the plugin itself failed, because Navidrome retries failed calls with backoff. -// //nd:capability name=metadata type MetadataAgent interface { // GetArtistMBID retrieves the MusicBrainz ID for an artist. @@ -107,13 +102,10 @@ 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 []types.ArtistRef `json:"artists"` + Artists []ArtistRef `json:"artists"` } // ImageInfo represents an image with URL and size. @@ -142,13 +134,32 @@ type TopSongsRequest struct { Count int32 `json:"count"` } -// Deprecated: use types.SongRef. -type SongRef = types.SongRef +// 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"` +} // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []types.SongRef `json:"songs"` + Songs []SongRef `json:"songs"` } // AlbumRequest is the common request for album-related functions. @@ -222,17 +233,5 @@ type SimilarSongsByArtistRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []types.SongRef `json:"songs"` + Songs []SongRef `json:"songs"` } - -// MetadataAgentError represents an error type for metadata agent operations. -type MetadataAgentError string - -const ( - // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. - // Append ":" inside the parentheses to request a specific delay. - MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" -) - -// Error implements the error interface for MetadataAgentError. -func (e MetadataAgentError) Error() string { return string(e) } diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml index 6e528c7e7..4940a5056 100644 --- a/plugins/capabilities/metadata_agent.yaml +++ b/plugins/capabilities/metadata_agent.yaml @@ -173,6 +173,20 @@ 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: @@ -317,6 +331,39 @@ 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: @@ -347,91 +394,3 @@ 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 c1c05839a..4918d5e8f 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -1,7 +1,5 @@ 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. @@ -34,6 +32,16 @@ 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. @@ -47,9 +55,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 []types.ArtistRef `json:"artists"` + Artists []ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []types.ArtistRef `json:"albumArtists"` + AlbumArtists []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 2b862b964..9d5cfed30 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -25,6 +25,20 @@ 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: @@ -180,31 +194,3 @@ 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 a35d9d923..aadb9396e 100644 --- a/plugins/capabilities/sonic_similarity.go +++ b/plugins/capabilities/sonic_similarity.go @@ -1,7 +1,5 @@ package capabilities -import "github.com/navidrome/navidrome/plugins/types" - // SonicSimilarity provides audio-similarity based track discovery. // //nd:capability name=sonicsimilarity required=true @@ -14,14 +12,14 @@ type SonicSimilarity interface { } type GetSonicSimilarTracksRequest struct { - Song types.SongRef `json:"song"` - Count int32 `json:"count"` + Song SongRef `json:"song"` + Count int32 `json:"count"` } type FindSonicPathRequest struct { - StartSong types.SongRef `json:"startSong"` - EndSong types.SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` } type SonicSimilarityResponse struct { @@ -29,6 +27,6 @@ type SonicSimilarityResponse struct { } type SonicMatch struct { - Song types.SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` } diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml index a336fbce8..cba97d9b0 100644 --- a/plugins/capabilities/sonic_similarity.yaml +++ b/plugins/capabilities/sonic_similarity.yaml @@ -39,6 +39,39 @@ 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: @@ -57,91 +90,3 @@ 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 7487db892..d2f67a60c 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] [-rust] +ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] [-go] [-python] [-rust] ``` ### Flags @@ -21,9 +21,10 @@ 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 `-rust` is not specified. Use `-go -rust` to generate both languages. +\* `-go` is enabled by default when neither `-python` nor `-rust` is specified. Use combinations like `-go -python -rust` to generate multiple languages. ### Example @@ -149,6 +150,10 @@ 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 6b081a826..af9fce441 100644 --- a/plugins/cmd/ndpgen/go.mod +++ b/plugins/cmd/ndpgen/go.mod @@ -1,13 +1,13 @@ module github.com/navidrome/navidrome/plugins/cmd/ndpgen -go 1.25.0 +go 1.25 require ( github.com/extism/go-pdk v1.1.3 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.44.0 + golang.org/x/tools v0.41.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.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 + 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 ) diff --git a/plugins/cmd/ndpgen/go.sum b/plugins/cmd/ndpgen/go.sum index 828273cab..952672d0e 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.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= +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= 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 bbe886f8d..db500c1fc 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -6,7 +6,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" . "github.com/onsi/ginkgo/v2" @@ -33,9 +32,6 @@ var _ = Describe("ndpgen CLI", Ordered, func() { // Build the ndpgen binary ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test") - if runtime.GOOS == "windows" { - ndpgenBin += ".exe" // Windows refuses to exec a file without it - } cmd := exec.Command("go", "build", "-o", ndpgenBin, ".") cmd.Dir = mustGetWd(GinkgoT()) output, err := cmd.CombinedOutput() @@ -180,15 +176,16 @@ type ServiceB interface { Describe("code generation", func() { DescribeTable("generates correct client output", - func(serviceFile, goClientExpectedFile, rsClientExpectedFile string) { + func(serviceFile, goClientExpectedFile, pyClientExpectedFile, 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, Rust) - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-rust") + // Generate all client code (Go, Python, Rust) + cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-python", "-rust") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) @@ -220,6 +217,17 @@ 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) @@ -243,59 +251,39 @@ type ServiceB interface { }, Entry("simple string params", - "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.rs"), + "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.py", "echo_client_expected.rs"), Entry("multiple simple params (int32)", - "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.rs"), + "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.py", "math_client_expected.rs"), Entry("struct param with request type", - "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.rs"), + "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.py", "store_client_expected.rs"), Entry("mixed simple and complex params", - "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.rs"), + "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.py", "list_client_expected.rs"), Entry("method without error", - "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.rs"), + "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.py", "counter_client_expected.rs"), Entry("no params, error only", - "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.rs"), + "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.py", "ping_client_expected.rs"), Entry("map and interface types", - "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.rs"), + "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.py", "meta_client_expected.rs"), Entry("pointer types", - "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.rs"), + "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.py", "users_client_expected.rs"), Entry("multiple returns", - "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.rs"), + "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.py", "search_client_expected.rs"), Entry("bytes", - "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.rs"), + "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.py", "codec_client_expected.rs"), Entry("option pattern (value, exists bool)", - "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.rs"), + "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "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") @@ -398,6 +386,119 @@ 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 bfc1b7c4e..705cd4d36 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -4,7 +4,6 @@ import ( "bytes" "embed" "fmt" - "slices" "strings" "text/template" ) @@ -27,46 +26,32 @@ func hostFuncMap(svc Service) template.FuncMap { // Uses private (lowercase) type names for request/response structs. func clientFuncMap(svc Service) template.FuncMap { return template.FuncMap{ - "lower": strings.ToLower, - "title": strings.Title, - "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, - "requestType": func(m Method) string { return m.ClientRequestTypeName(svc.Name) }, - "responseType": func(m Method) string { return m.ClientResponseTypeName(svc.Name) }, - "formatDoc": formatDoc, - "mockReturnBody": mockReturnBody, + "lower": strings.ToLower, + "title": strings.Title, + "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, + "requestType": func(m Method) string { return m.ClientRequestTypeName(svc.Name) }, + "responseType": func(m Method) string { return m.ClientResponseTypeName(svc.Name) }, + "formatDoc": formatDoc, + "mockReturnValues": mockReturnValues, } } -// mockReturnBody generates the testify mock body lines that extract return values. -// Nil-able returns are guarded so tests can use Return(nil, ...) without the -// type-assertion panic testify produces on an untyped nil. -func mockReturnBody(m Method) string { - var b strings.Builder +// mockReturnValues generates the testify mock return value accessors for a method. +// For example: args.String(0), args.Bool(1), args.Error(2) +func mockReturnValues(m Method) string { var parts []string + idx := 0 - for idx, r := range m.Returns { - if isNilableType(r.Type) { - name := fmt.Sprintf("r%d", idx) - fmt.Fprintf(&b, "\tvar %s %s\n\tif v := args.Get(%d); v != nil {\n\t\t%s = v.(%s)\n\t}\n", name, r.Type, idx, name, r.Type) - parts = append(parts, name) - } else { - parts = append(parts, mockAccessor(r.Type, idx)) - } + for _, r := range m.Returns { + parts = append(parts, mockAccessor(r.Type, idx)) + idx++ } if m.HasError { - parts = append(parts, fmt.Sprintf("args.Error(%d)", len(m.Returns))) + parts = append(parts, fmt.Sprintf("args.Error(%d)", idx)) } - b.WriteString("\treturn " + strings.Join(parts, ", ")) - return b.String() -} - -// isNilableType reports whether a nil mock return value is a valid intent for the -// type, rather than a malformed test expectation. -func isNilableType(typ string) bool { - return strings.HasPrefix(typ, "*") || strings.HasPrefix(typ, "[]") || - strings.HasPrefix(typ, "map[") || typ == "any" || typ == "interface{}" + return strings.Join(parts, ", ") } // mockAccessor returns the testify mock accessor call for a given type and index. @@ -94,6 +79,17 @@ 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") @@ -190,13 +186,51 @@ 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()) }, @@ -204,9 +238,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.RustTypeWithShared(knownStructs, shared) }, - "rustParamType": func(p Param) string { return p.RustParamTypeWithShared(knownStructs, shared) }, - "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) }, + "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) }, } } @@ -354,18 +388,6 @@ 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 { @@ -437,10 +459,6 @@ 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, @@ -448,25 +466,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 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) }, + "fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) }, + "rustOutputType": rustOutputType, + "isPrimitiveRust": isPrimitiveRustType, "skipSerializingFunc": skipSerializingFunc, "hasHashMap": hasHashMap, "agentName": capabilityAgentName, "providerInterface": func(e Export) string { return e.ProviderInterfaceName() }, "registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) }, - "rustSharedTarget": func(target string) string { - return "nd_pdk_types::" + strings.TrimPrefix(target, sharedTypesPrefix) + "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") }, - // 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, } } @@ -508,46 +526,6 @@ 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, "*") { @@ -590,16 +568,9 @@ 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 { - switch { - case strings.HasPrefix(goType, "*"): + if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") { 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": @@ -623,9 +594,9 @@ func skipSerializingFunc(goType string) string { } } -// anyFieldUsesHashMap returns true if any field in the given structs uses a map type. -func anyFieldUsesHashMap(structs []StructDef) bool { - for _, st := range structs { +// hasHashMap returns true if any struct in the capability uses HashMap. +func hasHashMap(cap Capability) bool { + for _, st := range cap.Structs { for _, f := range st.Fields { if strings.HasPrefix(f.Type, "map[") { return true @@ -635,32 +606,6 @@ func anyFieldUsesHashMap(structs []StructDef) 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 { @@ -717,11 +662,6 @@ 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) @@ -739,7 +679,7 @@ func pdkFuncMap() template.FuncMap { "returnList": pdkReturnList, "argList": pdkArgList, "argListWithReceiver": pdkArgListWithReceiver, - "mockReturnBody": pdkMockReturnBody, + "mockReturns": pdkMockReturns, "constValue": pdkConstValue, "stubTypeUnderlying": stubTypeUnderlying, "methodReceiver": pdkMethodReceiver, @@ -848,20 +788,12 @@ func pdkMethodReceiver(receiver, typeName string) string { } // pdkMockReturns generates the mock return accessors for a function. -func pdkMockReturnBody(returns []PDKReturn) string { - var b strings.Builder +func pdkMockReturns(returns []PDKReturn) string { var parts []string for i, r := range returns { - if isNilableType(r.Type) { - name := fmt.Sprintf("r%d", i) - fmt.Fprintf(&b, "\tvar %s %s\n\tif v := args.Get(%d); v != nil {\n\t\t%s = v.(%s)\n\t}\n", name, r.Type, i, name, r.Type) - parts = append(parts, name) - } else { - parts = append(parts, mockAccessorForType(r.Type, i)) - } + parts = append(parts, mockAccessorForType(r.Type, i)) } - b.WriteString("\treturn " + strings.Join(parts, ", ")) - return b.String() + return strings.Join(parts, ", ") } // mockAccessorForType returns the testify mock accessor for a type. @@ -955,70 +887,3 @@ 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 555906489..34c2c2886 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -287,42 +287,6 @@ 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() { @@ -454,19 +418,266 @@ var _ = Describe("Generator", func() { }) }) - 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("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")) + }) }) - 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("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", ""))`)) }) }) @@ -748,9 +959,8 @@ var _ = Describe("Generator", func() { // Check bool return uses args.Bool(1) Expect(codeStr).To(ContainSubstring("args.Bool(1)")) - // []byte is nil-able, so it gets a guarded extraction - Expect(codeStr).To(ContainSubstring("var r0 []byte")) - Expect(codeStr).To(ContainSubstring("r0 = v.([]byte)")) + // Check []byte return uses args.Get(0).([]byte) + Expect(codeStr).To(ContainSubstring("args.Get(0).([]byte)")) // Check error returns use args.Error(N) Expect(codeStr).To(ContainSubstring("args.Error(")) @@ -976,73 +1186,6 @@ 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() { @@ -1144,67 +1287,14 @@ 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 types", func() { + It("should return Option::is_none for pointer, slice, and map types", func() { Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none")) Expect(skipSerializingFunc("*MyStruct")).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")) + 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 String::is_empty for string type", func() { @@ -1392,109 +1482,6 @@ 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", @@ -1646,36 +1633,6 @@ 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", @@ -1700,124 +1657,6 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).NotTo(ContainSubstring("use base64")) }) }) - - Describe("nil-safe mock accessors", func() { - Describe("GenerateClientGoStub", func() { - It("guards pointer, slice and map returns so Return(nil, ...) does not panic", func() { - svc := Service{ - Name: "Paged", - Interface: "PagedService", - Methods: []Method{ - { - Name: "GetPage", - HasError: true, - Params: []Param{NewParam("query", "string")}, - Returns: []Param{NewParam("items", "[]Item"), NewParam("next", "*PageOptions")}, - }, - { - Name: "GetLabels", - HasError: true, - Params: []Param{NewParam("id", "string")}, - Returns: []Param{NewParam("labels", "map[string]string")}, - }, - }, - } - - code, err := GenerateClientGoStub(svc, "host") - Expect(err).NotTo(HaveOccurred()) - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - Expect(codeStr).To(ContainSubstring("var r0 []Item")) - Expect(codeStr).To(ContainSubstring("var r1 *PageOptions")) - Expect(codeStr).To(ContainSubstring("if v := args.Get(1); v != nil {")) - Expect(codeStr).To(ContainSubstring("r1 = v.(*PageOptions)")) - Expect(codeStr).To(ContainSubstring("return r0, r1, args.Error(2)")) - Expect(codeStr).To(ContainSubstring("var r0 map[string]string")) - Expect(codeStr).NotTo(ContainSubstring("args.Get(0).([]Item)")) - Expect(codeStr).NotTo(ContainSubstring("args.Get(1).(*PageOptions)")) - }) - - It("keeps non-nilable returns as inline accessors", func() { - svc := Service{ - Name: "Counter", - Interface: "CounterService", - Methods: []Method{ - { - Name: "Count", - HasError: true, - Params: []Param{NewParam("id", "string")}, - Returns: []Param{NewParam("count", "int64"), NewParam("name", "string")}, - }, - }, - } - - code, err := GenerateClientGoStub(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - Expect(codeStr).To(ContainSubstring("return args.Get(0).(int64), args.String(1), args.Error(2)")) - Expect(codeStr).NotTo(ContainSubstring("var r0")) - }) - }) - - Describe("GeneratePDKGoStub", func() { - It("guards pointer and slice returns", func() { - symbols := &PDKSymbols{ - Functions: []PDKFunc{ - { - Name: "NewHTTPRequest", - Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}}, - Returns: []PDKReturn{{Type: "*HTTPRequest"}}, - }, - { - Name: "Input", - Returns: []PDKReturn{{Type: "[]byte"}}, - }, - }, - } - - code, err := GeneratePDKGoStub(symbols) - Expect(err).NotTo(HaveOccurred()) - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - Expect(codeStr).To(ContainSubstring("var r0 *HTTPRequest")) - Expect(codeStr).To(ContainSubstring("r0 = v.(*HTTPRequest)")) - Expect(codeStr).To(ContainSubstring("var r0 []byte")) - Expect(codeStr).NotTo(ContainSubstring("return args.Get(0).(*HTTPRequest)")) - }) - }) - - Describe("Deprecated PDK functions", func() { - symbols := &PDKSymbols{ - Functions: []PDKFunc{ - { - Name: "NewHTTPRequest", - Doc: "NewHTTPRequest returns a new `HTTPRequest`.", - Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}}, - Returns: []PDKReturn{{Type: "*HTTPRequest"}}, - Deprecated: "Use host.HTTPSend instead.", - }, - }, - } - - DescribeTable("emits a Deprecated paragraph after the doc line", - func(generate func(*PDKSymbols) ([]byte, error)) { - code, err := generate(symbols) - Expect(err).NotTo(HaveOccurred()) - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - Expect(string(code)).To(ContainSubstring( - "// NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`.\n//\n// Deprecated: Use host.HTTPSend instead.\nfunc NewHTTPRequest(")) - }, - Entry("WASM wrapper", GeneratePDKGo), - Entry("native stub", GeneratePDKGoStub), - ) - }) - }) }) func writeFile(path, content string) error { diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go index 9f305efec..4cb28f8d4 100644 --- a/plugins/cmd/ndpgen/internal/parser.go +++ b/plugins/cmd/ndpgen/internal/parser.go @@ -27,86 +27,31 @@ var ( keyValuePattern = regexp.MustCompile(`(\w+)=(\S+)`) ) -// 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) { +// ParseDirectory parses all Go source files in a directory and extracts host services. +func ParseDirectory(dir string) ([]Service, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("reading directory: %w", err) } - var paths []string - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || !strings.HasSuffix(name, ".go") { - continue - } - 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 -} -// 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 - } - 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) + fset := token.NewFileSet() + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + continue } - services = append(services, svcList...) + // Skip generated files and test files + if strings.HasSuffix(entry.Name(), "_gen.go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + + path := filepath.Join(dir, entry.Name()) + parsed, err := parseFile(fset, path) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", entry.Name(), err) + } + services = append(services, parsed...) } return services, nil @@ -114,72 +59,66 @@ func ParseDirectoryWithShared(dir string, shared map[string]StructDef) ([]Servic // ParseCapabilities parses all Go source files in a directory and extracts capabilities. func ParseCapabilities(dir string) ([]Capability, error) { - 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) + entries, err := os.ReadDir(dir) if err != nil { - return nil, err + return nil, fmt.Errorf("reading directory: %w", err) } - // First pass: collect all structs, type aliases, and const groups. - pkgStructMap := make(map[string]StructDef) - pkgAliasMap := make(map[string]TypeAlias) + 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) var allConstGroups []ConstGroup - for _, pf := range parsed { - for _, s := range parseStructs(pf.file) { - pkgStructMap[s.Name] = s + var goFiles []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + continue } - for _, a := range parseTypeAliases(pf.file) { - pkgAliasMap[a.Name] = a + // 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 } - allConstGroups = append(allConstGroups, parseConstGroups(pf.file)...) + goFiles = append(goFiles, filepath.Join(dir, entry.Name())) } - // Second pass: parse capabilities using the package-level type maps. - var capabilities []Capability - for _, pf := range parsed { - capList, err := parseCapabilityFile(pf.path, pf.file, pkgStructMap, pkgAliasMap, allConstGroups, shared) + for _, path := range goFiles { + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) if err != nil { - return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) + return nil, fmt.Errorf("parsing %s for types: %w", filepath.Base(path), err) } - capabilities = append(capabilities, capList...) + 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 + var capabilities []Capability + for _, path := range goFiles { + parsed, err := parseCapabilityFile(fset, path, sharedStructMap, sharedAliasMap, allConstGroups) + if err != nil { + return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err) + } + capabilities = append(capabilities, parsed...) } return capabilities, nil } -// 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) { +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 + } + var capabilities []Capability for _, decl := range f.Decls { @@ -251,21 +190,6 @@ func parseCapabilityFile(path string, f *ast.File, structMap map[string]StructDe // 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)) @@ -276,11 +200,8 @@ func parseCapabilityFile(path string, f *ast.File, structMap map[string]StructDe } } - // Attach referenced type aliases (skip those already in SharedAliases) + // Attach referenced type aliases for _, typeName := range sortedTypeNames { - if sharedAliasNames[typeName] { - continue - } if a, exists := aliasMap[typeName]; exists { capability.TypeAliases = append(capability.TypeAliases, a) } @@ -290,9 +211,6 @@ func parseCapabilityFile(path string, f *ast.File, structMap map[string]StructDe // 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) @@ -319,111 +237,6 @@ func parseCapabilityFile(path string, f *ast.File, structMap map[string]StructDe 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 @@ -488,11 +301,20 @@ func parseExport(name string, funcType *ast.FuncType, annotation map[string]stri return export, nil } -// 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) { +// 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 + } + var services []Service for _, decl := range f.Decls { @@ -560,22 +382,9 @@ func parseServiceFile(f *ast.File, pkgStructMap map[string]StructDef, pkgAliasMa } } - // 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 := pkgStructMap[typeName]; exists { + if s, exists := structMap[typeName]; exists { service.Structs = append(service.Structs, s) } } @@ -660,10 +469,9 @@ 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), - IsAlias: typeSpec.Assign.IsValid(), + Name: typeSpec.Name.Name, + Type: typeToString(typeSpec.Type), + Doc: cleanDoc(docText), }) } } @@ -832,14 +640,6 @@ 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 bfdb93db5..f43578397 100644 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ b/plugins/cmd/ndpgen/internal/parser_test.go @@ -212,136 +212,6 @@ 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() { @@ -650,182 +520,6 @@ 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/pdk_parser.go b/plugins/cmd/ndpgen/internal/pdk_parser.go index 7607fe881..4756334dd 100644 --- a/plugins/cmd/ndpgen/internal/pdk_parser.go +++ b/plugins/cmd/ndpgen/internal/pdk_parser.go @@ -50,12 +50,6 @@ type PDKFunc struct { Params []PDKParam Returns []PDKReturn IsVariadic bool - Deprecated string // Rendered as a "Deprecated:" paragraph when set -} - -// deprecatedPDKFuncs marks extism functions that do not work inside Navidrome, with what to use instead. -var deprecatedPDKFuncs = map[string]string{ - "NewHTTPRequest": "Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead.", } // PDKParam represents a function parameter. @@ -162,7 +156,6 @@ func ParseExtismPDK() (*PDKSymbols, error) { t.Methods = append(t.Methods, fn) } } else { - fn.Deprecated = deprecatedPDKFuncs[fn.Name] symbols.Functions = append(symbols.Functions, fn) } } diff --git a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl index 8b88f367c..ebcd80739 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl @@ -8,21 +8,9 @@ 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 2ee7ece1d..790ed93e4 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 or .Capability.Structs .Capability.SharedAliases}} +{{if .Capability.Structs}} use serde::{Deserialize, Serialize}; {{- if hasHashMap .Capability}} use std::collections::HashMap; @@ -24,13 +24,6 @@ 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}} @@ -138,9 +131,9 @@ macro_rules! register_{{snakeCase .Package}} { #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} - req: extism_pdk::Json<{{rustMethodType .Input.Type}}> + req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{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}}> { let plugin = <$plugin_type>::default(); {{- if and .HasInput .HasOutput}} let result = $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; @@ -185,9 +178,9 @@ macro_rules! {{registerMacroName .Name}} { #[extism_pdk::plugin_fn] pub fn {{.ExportName}}( {{- if .HasInput}} - req: extism_pdk::Json<{{rustMethodType .Input.Type}}> + req: extism_pdk::Json<$crate::{{snakeCase $.Package}}::{{rustOutputType .Input.Type}}> {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{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}}> { 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 59a886f08..90f72be93 100644 --- a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl @@ -7,19 +7,6 @@ //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 b49680d1d..a6ee04446 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl @@ -12,23 +12,10 @@ 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 new file mode 100644 index 000000000..7ccaa6106 --- /dev/null +++ b/plugins/cmd/ndpgen/internal/templates/client.py.tmpl @@ -0,0 +1,111 @@ +# 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.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl index 786a50db5..f8b786849 100644 --- a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; {{if .Doc}} {{rustDocComment .Doc}} {{else}} -{{end}}#[derive(Debug, Clone, Default, Serialize, Deserialize)] +{{end}}#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct {{.Name}} { {{- range .Fields}} diff --git a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl index eb5e9f844..da19df666 100644 --- a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl @@ -8,21 +8,7 @@ package {{.Package}} -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}} +import "github.com/stretchr/testify/mock" {{- /* Generate struct definitions (same as main file, needed for type references in function signatures) */ -}} {{- range .Service.Structs}} @@ -51,7 +37,7 @@ var {{.Service.Name}}Mock = &mock{{.Service.Name}}Service{} // {{.Name}} is the mock method for {{$.Service.Name}}{{.Name}}. func (m *mock{{$.Service.Name}}Service) {{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) {{.ReturnSignature}} { args := m.Called({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}}{{end}}) -{{mockReturnBody .}} + return {{mockReturnValues .}} } // {{$.Service.Name}}{{.Name}} delegates to the mock instance. diff --git a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl index b1ac4dd36..083f7577e 100644 --- a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl @@ -7,10 +7,6 @@ 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/pdk.go.tmpl b/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl index 117bd9232..ebaf88df0 100644 --- a/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl @@ -40,10 +40,6 @@ const ( {{- if .Doc}} // {{.Name}} {{firstSentence .Doc}} {{- end}} -{{- if .Deprecated}} -// -// Deprecated: {{.Deprecated}} -{{- end}} func {{.Name}}({{paramList .Params}}){{returnList .Returns}} { {{- if .Returns}} return extism.{{.Name}}({{argList .Params}}) diff --git a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl index 27d06faa0..6a57a6469 100644 --- a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl @@ -31,14 +31,10 @@ func ResetMock() { {{- if .Doc}} // {{.Name}} {{firstSentence .Doc}} {{- end}} -{{- if .Deprecated}} -// -// Deprecated: {{.Deprecated}} -{{- end}} func {{.Name}}({{paramList .Params}}){{returnList .Returns}} { {{- if .Returns}} args := PDKMock.Called({{argList .Params}}) -{{mockReturnBody .Returns}} + return {{mockReturns .Returns}} {{- else}} PDKMock.Called({{argList .Params}}) {{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/types.go.tmpl b/plugins/cmd/ndpgen/internal/templates/types.go.tmpl deleted file mode 100644 index 698429b96..000000000 --- a/plugins/cmd/ndpgen/internal/templates/types.go.tmpl +++ /dev/null @@ -1,23 +0,0 @@ -// 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 deleted file mode 100644 index b7bc131ff..000000000 --- a/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl +++ /dev/null @@ -1,49 +0,0 @@ -// 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 09657d09e..6132dfbc4 100644 --- a/plugins/cmd/ndpgen/internal/types.go +++ b/plugins/cmd/ndpgen/internal/types.go @@ -5,59 +5,34 @@ 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 - SharedAliases []SharedAlias // Aliases to types in the shared `types` package + 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 } // 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") - 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) + 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") } -// TypeAlias represents a type declaration (e.g. type ScrobblerErrorType string) -// or a Go type alias (e.g. type TrackInfo = types.Track). +// TypeAlias represents a type alias definition (e.g., type ScrobblerErrorType string). type TypeAlias struct { - 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) + Name string // Type name + Type string // Underlying type + Doc string // Documentation comment } // ConstGroup represents a group of const definitions. @@ -73,90 +48,12 @@ type ConstDef struct { Doc string // Documentation comment } -// 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. +// KnownStructs returns a map of struct names defined in this capability. 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 } @@ -257,16 +154,12 @@ func (s Service) ExportPrefix() string { return strings.ToLower(s.Name) } -// KnownStructs returns a map of struct names defined in this service, -// including shared-alias names so Rust field-type resolution finds them. +// KnownStructs returns a map of struct names defined in this service. 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 } @@ -534,6 +427,24 @@ 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 { @@ -558,6 +469,31 @@ 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) @@ -626,13 +562,6 @@ 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) @@ -646,15 +575,6 @@ 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) @@ -684,18 +604,9 @@ 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 := toRustType(goType[1:], knownStructs, shared) + inner := ToRustTypeWithStructs(goType[1:], knownStructs) return "Option<" + inner + ">" } // Handle slice types @@ -703,7 +614,7 @@ func toRustType(goType string, knownStructs map[string]bool, shared map[string]s if goType == "[]byte" { return "Vec" } - inner := toRustType(goType[2:], knownStructs, shared) + inner := ToRustTypeWithStructs(goType[2:], knownStructs) return "Vec<" + inner + ">" } // Handle map types @@ -725,7 +636,7 @@ func toRustType(goType string, knownStructs map[string]bool, shared map[string]s } keyType := rest[:keyEnd] valueType := rest[keyEnd+1:] - return "std::collections::HashMap<" + toRustType(keyType, knownStructs, shared) + ", " + toRustType(valueType, knownStructs, shared) + ">" + return "std::collections::HashMap<" + ToRustTypeWithStructs(keyType, knownStructs) + ", " + ToRustTypeWithStructs(valueType, knownStructs) + ">" } switch goType { @@ -748,17 +659,6 @@ func toRustType(goType string, knownStructs map[string]bool, shared map[string]s 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 20c067ffa..cc2a7d0e0 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema.go @@ -60,18 +60,16 @@ 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, aliasToCanonical)) + addToMap(&schema.Exports, export.ExportName, buildExport(export)) } } // Build components/schemas - schemas := buildSchemas(cap, aliasToCanonical) + schemas := buildSchemas(cap) if len(schemas.Content) > 0 { schema.Components = &xtpComponents{Schemas: schemas} } @@ -79,22 +77,11 @@ func GenerateSchema(cap Capability) ([]byte, error) { return yaml.Marshal(schema) } -// 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 { +func buildExport(export Export) xtpExport { e := xtpExport{Description: cleanDocForYAML(export.Doc)} if export.Input.Type != "" { e.Input = &xtpIOParam{ - Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(export.Input.Type), aliasToCanonical), + Ref: "#/components/schemas/" + strings.TrimPrefix(export.Input.Type, "*"), ContentType: "application/json", } } @@ -108,7 +95,7 @@ func buildExport(export Export, aliasToCanonical map[string]string) xtpExport { } } else { e.Output = &xtpIOParam{ - Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(outputType), aliasToCanonical), + Ref: "#/components/schemas/" + outputType, ContentType: "application/json", } } @@ -125,38 +112,15 @@ func isPrimitiveGoType(goType string) bool { return false } -func buildSchemas(cap Capability, aliasToCanonical map[string]string) yaml.Node { +func buildSchemas(cap Capability) 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, 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 - } - } + usedTypes := collectUsedTypes(cap, knownTypes) // Sort structs by name for consistent output structNames := make([]string, 0, len(cap.Structs)) @@ -171,19 +135,7 @@ func buildSchemas(cap Capability, aliasToCanonical map[string]string) yaml.Node for _, name := range structNames { st := structMap[name] - 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)) + addToMap(&schemas, name, buildObjectSchema(st, knownTypes)) } // Build enum types from type aliases (only if used by exports) @@ -205,18 +157,18 @@ func buildSchemas(cap Capability, aliasToCanonical map[string]string) yaml.Node } // collectUsedTypes returns a set of type names that are reachable from exports. -func collectUsedTypes(cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef) map[string]bool { +func collectUsedTypes(cap Capability, knownTypes map[string]bool) 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, sharedDefs, used) + addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, used) } if export.Output.Type != "" { outputType := strings.TrimPrefix(export.Output.Type, "*") if !isPrimitiveGoType(outputType) { - addTypeAndDeps(outputType, cap, knownTypes, sharedDefs, used) + addTypeAndDeps(outputType, cap, knownTypes, used) } } } @@ -225,55 +177,28 @@ func collectUsedTypes(cap Capability, knownTypes map[string]bool, sharedDefs map } // addTypeAndDeps adds a type and all its dependencies to the used set. -func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef, used map[string]bool) { - typeName = strings.TrimPrefix(typeName, sharedTypesPrefix) +func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, used map[string]bool) { if used[typeName] || !knownTypes[typeName] { return } used[typeName] = true - // Walk fields of capability-local structs. + // Find the struct and add its field types for _, st := range cap.Structs { if st.Name == typeName { for _, field := range st.Fields { - if base := fieldBaseType(field.Type); knownTypes[base] { - addTypeAndDeps(base, cap, knownTypes, sharedDefs, used) + fieldType := strings.TrimPrefix(field.Type, "*") + fieldType = strings.TrimPrefix(fieldType, "[]") + if knownTypes[fieldType] { + addTypeAndDeps(fieldType, cap, knownTypes, 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) - } - } - } } -// 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 { +func buildObjectSchema(st StructDef, knownTypes map[string]bool) xtpObjectSchema { schema := xtpObjectSchema{ Description: cleanDocForYAML(st.Doc), Properties: yaml.Node{Kind: yaml.MappingNode}, @@ -281,7 +206,7 @@ func buildObjectSchema(st StructDef, knownTypes map[string]bool, aliasToCanonica for _, field := range st.Fields { propName := getJSONFieldName(field) - addToMap(&schema.Properties, propName, buildProperty(field, knownTypes, aliasToCanonical)) + addToMap(&schema.Properties, propName, buildProperty(field, knownTypes)) if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty { schema.Required = append(schema.Required, propName) @@ -303,7 +228,7 @@ func buildEnumSchema(alias TypeAlias, cg ConstGroup) xtpEnumSchema { } } -func buildProperty(field FieldDef, knownTypes map[string]bool, aliasToCanonical map[string]string) xtpProperty { +func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty { goType := field.Type isPointer := strings.HasPrefix(goType, "*") if isPointer { @@ -315,10 +240,9 @@ func buildProperty(field FieldDef, knownTypes map[string]bool, aliasToCanonical Nullable: isPointer, } - // 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) + // Handle reference types (use $ref instead of type) + if isKnownType(goType, knownTypes) && !strings.HasPrefix(goType, "[]") { + prop.Ref = "#/components/schemas/" + goType return prop } @@ -330,11 +254,11 @@ func buildProperty(field FieldDef, knownTypes map[string]bool, aliasToCanonical // Handle slice types if strings.HasPrefix(goType, "[]") { - elemType := strings.TrimPrefix(goType[2:], sharedTypesPrefix) + elemType := goType[2:] prop.Type = "array" prop.Items = &xtpProperty{} if isKnownType(elemType, knownTypes) { - prop.Items.Ref = "#/components/schemas/" + canonicalRefName(elemType, aliasToCanonical) + prop.Items.Ref = "#/components/schemas/" + elemType } 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 f8702aa4d..2e28a75d8 100644 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ b/plugins/cmd/ndpgen/internal/xtp_schema_test.go @@ -700,115 +700,6 @@ 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 6520a3ecd..b34ee4296 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/rust/host/ +// - Host functions: $output/go/host/, $output/python/host/, $output/rust/host/ // - Capabilities: $output/go// (e.g., $output/go/metadata/) // - Schemas: $input/.yaml (co-located with Go sources) // @@ -32,7 +32,8 @@ // -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 -rust) +// -go Generate Go client wrappers (default: true when not using -python/-rust) +// -python Generate Python client wrappers (default: false) // -rust Generate Rust client wrappers (default: false) // -v Verbose output // -dry-run Preview generated code without writing files @@ -42,10 +43,8 @@ import ( "flag" "fmt" "go/format" - "maps" "os" "path/filepath" - "slices" "strings" "github.com/navidrome/navidrome/plugins/cmd/ndpgen/internal" @@ -56,16 +55,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 - sharedTypes bool // Generate the shared types package - sharedDir string // Directory of shared types to load as a registry + schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir) + pdkOnly bool // Generate PDK abstraction layer wrapper generateGoClient bool + generatePyClient bool generateRsClient bool verbose bool dryRun bool @@ -86,14 +85,6 @@ 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) @@ -166,69 +157,6 @@ 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. @@ -387,9 +315,8 @@ 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") @@ -413,9 +340,6 @@ func parseConfig() (*config, error) { if *pdkOnly { modeCount++ } - if *sharedTypes { - modeCount++ - } // Default to host-only if no mode is specified if modeCount == 0 { @@ -424,7 +348,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, -extism-pdk, -shared-types)") + return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -host-wrappers, -capability-only, -schemas, -pdk)") } if *outputDir == "" { @@ -448,28 +372,23 @@ 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 || *rsClient + anyLangFlag := *goClient || *pyClient || *rsClient return &config{ inputDir: absInput, outputDir: absOutput, goOutputDir: absGoOutput, + pythonOutputDir: absPythonOutput, rustOutputDir: absRustOutput, pkgName: *pkgName, hostOnly: *hostOnly, @@ -477,9 +396,8 @@ 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, @@ -494,21 +412,20 @@ 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) } - 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) + services, err := internal.ParseDirectory(cfg.inputDir) if err != nil { return nil, fmt.Errorf("parsing source files: %w", err) } @@ -538,12 +455,7 @@ func parseCapabilities(cfg *config) ([]internal.Capability, error) { fmt.Printf("Capability-only mode: %v\n", cfg.capabilityOnly) } - 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) + capabilities, err := internal.ParseCapabilities(cfg.inputDir) if err != nil { return nil, fmt.Errorf("parsing capability files: %w", err) } @@ -707,6 +619,11 @@ 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) @@ -840,6 +757,36 @@ 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 new file mode 100644 index 000000000..5142ffd0e --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/codec_client_expected.py @@ -0,0 +1,53 @@ +# 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 new file mode 100644 index 000000000..93370ddcf --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.py @@ -0,0 +1,342 @@ +# 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/comprehensive_client_expected.rs b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs index 1bd953640..08dae2901 100644 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs @@ -29,14 +29,14 @@ mod base64_bytes { } } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User2 { pub id: String, pub name: String, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Filter2 { pub active: bool, diff --git a/plugins/cmd/ndpgen/testdata/config_client_expected.py b/plugins/cmd/ndpgen/testdata/config_client_expected.py new file mode 100644 index 000000000..370de6d10 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/config_client_expected.py @@ -0,0 +1,126 @@ +# 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 new file mode 100644 index 000000000..872d407bb --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/counter_client_expected.py @@ -0,0 +1,49 @@ +# 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 new file mode 100644 index 000000000..06565b0d6 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/echo_client_expected.py @@ -0,0 +1,52 @@ +# 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 new file mode 100644 index 000000000..58ccad146 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/list_client_expected.py @@ -0,0 +1,54 @@ +# 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/list_client_expected.rs b/plugins/cmd/ndpgen/testdata/list_client_expected.rs index 5227cadf4..9b54f7544 100644 --- a/plugins/cmd/ndpgen/testdata/list_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/list_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Filter { pub active: bool, diff --git a/plugins/cmd/ndpgen/testdata/math_client_expected.py b/plugins/cmd/ndpgen/testdata/math_client_expected.py new file mode 100644 index 000000000..f3ea53335 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/math_client_expected.py @@ -0,0 +1,54 @@ +# 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 new file mode 100644 index 000000000..4d20c73ff --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/meta_client_expected.py @@ -0,0 +1,81 @@ +# 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 new file mode 100644 index 000000000..4c7d41d8e --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/ping_client_expected.py @@ -0,0 +1,42 @@ +# 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 new file mode 100644 index 000000000..aa2e98a36 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/search_client_expected.py @@ -0,0 +1,62 @@ +# 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/search_client_expected.rs b/plugins/cmd/ndpgen/testdata/search_client_expected.rs index 396a22b4e..b0ab2505a 100644 --- a/plugins/cmd/ndpgen/testdata/search_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/search_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Result { pub id: String, diff --git a/plugins/cmd/ndpgen/testdata/store_client_expected.py b/plugins/cmd/ndpgen/testdata/store_client_expected.py new file mode 100644 index 000000000..4a964a497 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/store_client_expected.py @@ -0,0 +1,52 @@ +# 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/store_client_expected.rs b/plugins/cmd/ndpgen/testdata/store_client_expected.rs index 3b9d5ac17..25d2af2e0 100644 --- a/plugins/cmd/ndpgen/testdata/store_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/store_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Item { pub id: String, diff --git a/plugins/cmd/ndpgen/testdata/users_client_expected.py b/plugins/cmd/ndpgen/testdata/users_client_expected.py new file mode 100644 index 000000000..468b87b98 --- /dev/null +++ b/plugins/cmd/ndpgen/testdata/users_client_expected.py @@ -0,0 +1,54 @@ +# 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/cmd/ndpgen/testdata/users_client_expected.rs b/plugins/cmd/ndpgen/testdata/users_client_expected.rs index 4bac43ce8..40daa9cfd 100644 --- a/plugins/cmd/ndpgen/testdata/users_client_expected.rs +++ b/plugins/cmd/ndpgen/testdata/users_client_expected.rs @@ -6,7 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User { pub id: String, diff --git a/plugins/examples/README.md b/plugins/examples/README.md index 8e2dc5a85..bce2b6762 100644 --- a/plugins/examples/README.md +++ b/plugins/examples/README.md @@ -108,7 +108,7 @@ Generate boilerplate from a schema: # Install XTP: https://docs.xtp.dylibso.com/docs/cli xtp plugin init \ - --schema-file ../capabilities/metadata_agent.yaml \ + --schema-file ../schemas/metadata_agent.yaml \ --template go \ --path ./my-plugin \ --name my-plugin @@ -119,7 +119,7 @@ xtp plugin build zip -j my-plugin.ndp manifest.json dist/plugin.wasm ``` -Available schemas in [../capabilities/](../capabilities/): +Available schemas in [../schemas/](../schemas/): - `metadata_agent.yaml` – Artist/album metadata - `scrobbler.yaml` – Scrobbling integration - `lifecycle.yaml` – Init callbacks diff --git a/plugins/examples/coverartarchive-py/Makefile b/plugins/examples/coverartarchive-py/Makefile index c47c242d7..e3cd60d1c 100644 --- a/plugins/examples/coverartarchive-py/Makefile +++ b/plugins/examples/coverartarchive-py/Makefile @@ -1,5 +1,5 @@ # Build the Cover Art Archive Python plugin -.PHONY: build clean +.PHONY: build test clean WASM_FILE = coverartarchive-py.wasm @@ -8,5 +8,20 @@ build: $(WASM_FILE) $(WASM_FILE): plugin/__init__.py extism-py plugin/__init__.py -o $(WASM_FILE) +test: build + @echo "Testing nd_manifest..." + extism call $(WASM_FILE) nd_manifest --wasi + @echo "" + @echo "Testing nd_get_album_images with Portishead's Dummy MBID..." + extism call $(WASM_FILE) nd_get_album_images --wasi \ + --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ + --allow-host "coverartarchive.org" --allow-host "archive.org" + +test-error: build + @echo "Testing error case (missing MBID)..." + -extism call $(WASM_FILE) nd_get_album_images --wasi \ + --input '{"name":"Test Album","artist":"Test Artist"}' \ + --allow-host "coverartarchive.org" + clean: rm -f $(WASM_FILE) diff --git a/plugins/examples/coverartarchive-py/README.md b/plugins/examples/coverartarchive-py/README.md index c779f4a43..77957ac4f 100644 --- a/plugins/examples/coverartarchive-py/README.md +++ b/plugins/examples/coverartarchive-py/README.md @@ -51,7 +51,14 @@ zip -j coverartarchive-py.ndp manifest.json plugin.wasm ## Testing -The plugin makes HTTP requests through Navidrome's `http_send` host function, so it only runs inside Navidrome (the `extism` CLI can't provide that function). Install the `.ndp` as described above, then open an album that has a MusicBrainz ID and check the Navidrome logs. +Extract the wasm file and test: + +```bash +unzip -p coverartarchive-py.ndp plugin.wasm > coverartarchive-py.wasm +extism call coverartarchive-py.wasm nd_get_album_images --wasi \ + --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ + --allow-host "coverartarchive.org" --allow-host "archive.org" +``` ## How It Works diff --git a/plugins/examples/coverartarchive-py/plugin/__init__.py b/plugins/examples/coverartarchive-py/plugin/__init__.py index c4a0480b5..3c1d4149e 100644 --- a/plugins/examples/coverartarchive-py/plugin/__init__.py +++ b/plugins/examples/coverartarchive-py/plugin/__init__.py @@ -5,27 +5,16 @@ # # Build with: # extism-py plugin/__init__.py -o coverartarchive-py.wasm +# +# Test with: +# extism call coverartarchive-py.wasm nd_get_album_images --wasi \ +# --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ +# --allow-host "coverartarchive.org" --allow-host "archive.org" -import base64 import extism import json -@extism.import_fn("extism:host/user", "http_send") -def _http_send(offset: int) -> int: ... - - -def http_get(url): - """GET url via Navidrome's HTTP host service. Returns (status_code, body_bytes).""" - request = json.dumps({"request": {"method": "GET", "url": url}}).encode("utf-8") - response_offset = _http_send(extism.memory.alloc(request).offset) - resp = json.loads(extism.memory.string(extism.memory.find(response_offset))) - if resp.get("error"): - raise Exception(f"HTTP request failed: {resp['error']}") - result = resp["result"] - return result["statusCode"], base64.b64decode(result.get("body", "")) - - @extism.plugin_fn def nd_get_album_images(): """Retrieve album cover images from Cover Art Archive.""" @@ -37,13 +26,13 @@ def nd_get_album_images(): # Query Cover Art Archive API url = f"https://coverartarchive.org/release/{mbid}" - status, body = http_get(url) - - if status != 200: - raise Exception(f"not found: CAA returned status {status}") - + response = extism.Http.request(url, meth="GET") + + if response.status_code != 200: + raise Exception(f"not found: CAA returned status {response.status_code}") + try: - data = json.loads(body) + data = json.loads(response.data_str()) except json.JSONDecodeError: raise Exception("not found: invalid JSON response") diff --git a/plugins/examples/discord-rich-presence-rs/src/lib.rs b/plugins/examples/discord-rich-presence-rs/src/lib.rs index 10c8f8c66..12bf9ed3e 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, PlaybackReportRequest, + Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest, ScrobbleRequest, Scrobbler, SCROBBLER_ERROR_NOT_AUTHORIZED, SCROBBLER_ERROR_RETRY_LATER, }; use nd_pdk::scheduler::{ @@ -207,11 +207,6 @@ 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/discord-rich-presence-rs/src/rpc.rs b/plugins/examples/discord-rich-presence-rs/src/rpc.rs index fa72f7466..3de9eff63 100644 --- a/plugins/examples/discord-rich-presence-rs/src/rpc.rs +++ b/plugins/examples/discord-rich-presence-rs/src/rpc.rs @@ -4,7 +4,7 @@ //! presence updates, and heartbeat management. use extism_pdk::*; -use nd_pdk::host::{cache, http, scheduler, websocket}; +use nd_pdk::host::{cache, scheduler, websocket}; use serde::{Deserialize, Serialize}; // ============================================================================ @@ -359,32 +359,19 @@ fn find_username_for_connection(connection_id: &str) -> Result, E Ok(cache::get_string(&reverse_key)?.filter(|s| !s.is_empty())) } -fn send_http( - method: &str, - url: &str, - headers: std::collections::HashMap, - body: Vec, -) -> Result { - http::send(http::HTTPRequest { - method: method.into(), - url: url.into(), - headers, - body, - ..Default::default() - })? - .ok_or_else(|| Error::msg("empty HTTP response")) -} - fn get_discord_gateway() -> Result { - let resp = send_http("GET", "https://discord.com/api/gateway", Default::default(), Vec::new())?; - if resp.status_code >= 400 { + let req = HttpRequest::new("https://discord.com/api/gateway") + .with_method("GET"); + + let resp = http::request::(&req, None::)?; + if resp.status_code() >= 400 { return Err(Error::msg(format!( "Failed to get Discord gateway: HTTP {}", - resp.status_code + resp.status_code() ))); } - let body = resp.body; + let body = resp.body(); let data: std::collections::HashMap = serde_json::from_slice(&body) .map_err(|e| Error::msg(format!("Failed to parse gateway response: {}", e)))?; @@ -500,22 +487,23 @@ fn process_image_inner( client_id ); - let headers = std::collections::HashMap::from([ - ("Authorization".to_string(), token.to_string()), - ("Content-Type".to_string(), "application/json".to_string()), - ]); - let resp = send_http("POST", &api_url, headers, body.into_bytes())?; - if resp.status_code >= 400 { + let req = HttpRequest::new(&api_url) + .with_method("POST") + .with_header("Authorization", token) + .with_header("Content-Type", "application/json"); + + let resp = http::request::(&req, Some(body))?; + if resp.status_code() >= 400 { if is_default { return Err(Error::msg(format!( "failed to process default image: HTTP {}", - resp.status_code + resp.status_code() ))); } return process_image_inner(DEFAULT_IMAGE, client_id, token, true); } - let body = resp.body; + let body = resp.body(); let data: Vec> = serde_json::from_slice(&body) .map_err(|e| Error::msg(format!("Failed to parse image response: {}", e)))?; diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs index 5f5d972a0..e872d845d 100644 --- a/plugins/examples/webhook-rs/src/lib.rs +++ b/plugins/examples/webhook-rs/src/lib.rs @@ -12,10 +12,9 @@ //! urls = "https://example.com/webhook1,https://example.com/webhook2" //! ``` -use extism_pdk::{config, error, info, warn}; -use nd_pdk::host::http::{self, HTTPRequest}; +use extism_pdk::{config, error, http, info, warn, HttpRequest}; use nd_pdk::scrobbler::{ - Error, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, + Error, IsAuthorizedRequest, NowPlayingRequest, ScrobbleRequest, Scrobbler, }; @@ -46,15 +45,6 @@ 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 @@ -91,15 +81,11 @@ impl Scrobbler for WebhookPlugin { let full_url = format!("{}{}", url, query); info!("Sending webhook to: {}", full_url); - let http_req = HTTPRequest { - method: "GET".into(), - url: full_url, - ..Default::default() - }; - match http::send(http_req) { + let http_req = HttpRequest::new(&full_url); + match http::request::<()>(&http_req, None) { Ok(res) => { - let status = res.map_or(0, |r| r.status_code); - if (200..300).contains(&status) { + let status = res.status_code(); + if status >= 200 && status < 300 { info!("Webhook succeeded: {} (status {})", url, status); } else { warn!("Webhook returned non-2xx status: {} (status {})", url, status); diff --git a/plugins/host/matcher.go b/plugins/host/matcher.go deleted file mode 100644 index 678d3bfaa..000000000 --- a/plugins/host/matcher.go +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 1bcd0dc3b..000000000 --- a/plugins/host/matcher_gen.go +++ /dev/null @@ -1,91 +0,0 @@ -// 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/scrobble_retriever.go b/plugins/host/scrobble_retriever.go deleted file mode 100644 index 2582dd650..000000000 --- a/plugins/host/scrobble_retriever.go +++ /dev/null @@ -1,89 +0,0 @@ -package host - -import "context" - -// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) -type ScrobbleRef struct { - // The ID of the scrobble. Useful if duplicate scrobbles happen for the same time - ID int64 `json:"id"` - // The ID of the MediaFile submitted at this time - MediaFileID string `json:"mediaFileId"` - // The UNIX timestamp this scrobble was submitted - SubmissionTime int64 `json:"submissionTime"` -} - -// ScrobbleOptions carries optional parameters for retrieving user scrobbles -type ScrobbleOptions struct { - // The starting unix timestamp to query for scrobbles (inclusive). - // If not specified, start from the first scrobble - FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - // The ending unix timestamp to query for scrobbles (inclusive). - // If not specified, go up to the last scrobble - ToTimestamp *int64 `json:"toTimestamp,omitempty"` - // If true, return scrobbles from newest to oldest. Defaults to oldest first - Descending bool `json:"descending"` - // The maximum number of items to retrieve. This is capped at 5000, the - // default if not specified - MaxItems int `json:"maxItems"` - // Pagination state managed by GetScrobbles; only meaningful on the options it returns. - // Never set it or combine it with your own timestamps — scrobbles may be silently skipped - Offset int `json:"offset,omitempty"` -} - -// ScrobbleCountOptions carries optional parameters for counting user scrobbles -type ScrobbleCountOptions struct { - // The starting unix timestamp to query for scrobbles (inclusive). - // If not specified, start from the first scrobble - FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - // The ending unix timestamp to query for scrobbles (inclusive). - // If not specified, go up to the last scrobble - ToTimestamp *int64 `json:"toTimestamp,omitempty"` -} - -// ScrobbleRetrieverService allows a plugin to retrieve scrobbles for one or more authorized users. -// It will only provide the media_file ID and submission time, which can be combined with the MatcherService -// to fetch deduped tracks -// -//nd:hostservice name=ScrobbleRetriever permission=scrobbleRetriever -type ScrobbleRetrieverService interface { - // GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. - // If the user has no scrobbles, returns nil - //nd:hostfunc - GetFirstTimestamp(ctx context.Context, username string) (*int64, error) - - // GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user - // If the user has no scrobbles, return nil - //nd:hostfunc - GetLastTimestamp(ctx context.Context, username string) (*int64, error) - - // GetScrobbles returns one page of scrobbles for a user. - // - // Parameters: - // - username: the user to query for scrobbles - // - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble - // - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble - // - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest - // - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 - // - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually - // - // Returns: - // - scrobbles: The scrobbles in the requested range, ordered by submission time - // (ties broken by scrobble ID) in the direction given by options.Descending - // - next: The options for the following page, or nil once no scrobbles remain. - // Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an - // adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range - //nd:hostfunc - GetScrobbles(ctx context.Context, username string, options ScrobbleOptions) (scrobbles []ScrobbleRef, next *ScrobbleOptions, err error) - - // GetScrobbleCount returns the number of scrobbles for a user in a given range - // - // Parameters: - // - username: the user to query for scrobbles - // - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble - // - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble - // - // Returns: - // - the number of scrobbles in the given range, or 0 - //nd:hostfunc - GetScrobbleCount(ctx context.Context, username string, options ScrobbleCountOptions) (int64, error) -} diff --git a/plugins/host/scrobbleretriever_gen.go b/plugins/host/scrobbleretriever_gen.go deleted file mode 100644 index 7a00c9c1f..000000000 --- a/plugins/host/scrobbleretriever_gen.go +++ /dev/null @@ -1,230 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// ScrobbleRetrieverGetFirstTimestampRequest is the request type for ScrobbleRetriever.GetFirstTimestamp. -type ScrobbleRetrieverGetFirstTimestampRequest struct { - Username string `json:"username"` -} - -// ScrobbleRetrieverGetFirstTimestampResponse is the response type for ScrobbleRetriever.GetFirstTimestamp. -type ScrobbleRetrieverGetFirstTimestampResponse struct { - Result *int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// ScrobbleRetrieverGetLastTimestampRequest is the request type for ScrobbleRetriever.GetLastTimestamp. -type ScrobbleRetrieverGetLastTimestampRequest struct { - Username string `json:"username"` -} - -// ScrobbleRetrieverGetLastTimestampResponse is the response type for ScrobbleRetriever.GetLastTimestamp. -type ScrobbleRetrieverGetLastTimestampResponse struct { - Result *int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// ScrobbleRetrieverGetScrobblesRequest is the request type for ScrobbleRetriever.GetScrobbles. -type ScrobbleRetrieverGetScrobblesRequest struct { - Username string `json:"username"` - Options ScrobbleOptions `json:"options"` -} - -// ScrobbleRetrieverGetScrobblesResponse is the response type for ScrobbleRetriever.GetScrobbles. -type ScrobbleRetrieverGetScrobblesResponse struct { - Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"` - Next *ScrobbleOptions `json:"next,omitempty"` - Error string `json:"error,omitempty"` -} - -// ScrobbleRetrieverGetScrobbleCountRequest is the request type for ScrobbleRetriever.GetScrobbleCount. -type ScrobbleRetrieverGetScrobbleCountRequest struct { - Username string `json:"username"` - Options ScrobbleCountOptions `json:"options"` -} - -// ScrobbleRetrieverGetScrobbleCountResponse is the response type for ScrobbleRetriever.GetScrobbleCount. -type ScrobbleRetrieverGetScrobbleCountResponse struct { - Result int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterScrobbleRetrieverHostFunctions registers ScrobbleRetriever service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterScrobbleRetrieverHostFunctions(service ScrobbleRetrieverService) []extism.HostFunction { - return []extism.HostFunction{ - newScrobbleRetrieverGetFirstTimestampHostFunction(service), - newScrobbleRetrieverGetLastTimestampHostFunction(service), - newScrobbleRetrieverGetScrobblesHostFunction(service), - newScrobbleRetrieverGetScrobbleCountHostFunction(service), - } -} - -func newScrobbleRetrieverGetFirstTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scrobbleretriever_getfirsttimestamp", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - var req ScrobbleRetrieverGetFirstTimestampRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.GetFirstTimestamp(ctx, req.Username) - if svcErr != nil { - scrobbleretrieverWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ScrobbleRetrieverGetFirstTimestampResponse{ - Result: result, - } - scrobbleretrieverWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newScrobbleRetrieverGetLastTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scrobbleretriever_getlasttimestamp", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - var req ScrobbleRetrieverGetLastTimestampRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.GetLastTimestamp(ctx, req.Username) - if svcErr != nil { - scrobbleretrieverWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ScrobbleRetrieverGetLastTimestampResponse{ - Result: result, - } - scrobbleretrieverWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newScrobbleRetrieverGetScrobblesHostFunction(service ScrobbleRetrieverService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scrobbleretriever_getscrobbles", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - var req ScrobbleRetrieverGetScrobblesRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - - // Call the service method - scrobbles, next, svcErr := service.GetScrobbles(ctx, req.Username, req.Options) - if svcErr != nil { - scrobbleretrieverWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ScrobbleRetrieverGetScrobblesResponse{ - Scrobbles: scrobbles, - Next: next, - } - scrobbleretrieverWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newScrobbleRetrieverGetScrobbleCountHostFunction(service ScrobbleRetrieverService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scrobbleretriever_getscrobblecount", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - var req ScrobbleRetrieverGetScrobbleCountRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.GetScrobbleCount(ctx, req.Username, req.Options) - if svcErr != nil { - scrobbleretrieverWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ScrobbleRetrieverGetScrobbleCountResponse{ - Result: result, - } - scrobbleretrieverWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// scrobbleretrieverWriteResponse writes a JSON response to plugin memory. -func scrobbleretrieverWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// scrobbleretrieverWriteError writes an error response to plugin memory. -func scrobbleretrieverWriteError(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/storage.go b/plugins/host/storage.go deleted file mode 100644 index ed24d7508..000000000 --- a/plugins/host/storage.go +++ /dev/null @@ -1,13 +0,0 @@ -package host - -import "context" - -// StorageService provides access to a plugin-specific directory with read/write permissions -// -//nd:hostservice name=Storage permission=storage -type StorageService interface { - // GetStoragePath retrieves the persistent storage path, if allowed - // - //nd:hostfunc - GetStoragePath(ctx context.Context) string -} diff --git a/plugins/host/storage_gen.go b/plugins/host/storage_gen.go deleted file mode 100644 index d705f0831..000000000 --- a/plugins/host/storage_gen.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// StorageGetStoragePathResponse is the response type for Storage.GetStoragePath. -type StorageGetStoragePathResponse struct { - Result string `json:"result,omitempty"` -} - -// RegisterStorageHostFunctions registers Storage service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterStorageHostFunctions(service StorageService) []extism.HostFunction { - return []extism.HostFunction{ - newStorageGetStoragePathHostFunction(service), - } -} - -func newStorageGetStoragePathHostFunction(service StorageService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "storage_getstoragepath", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - - // Call the service method - result := service.GetStoragePath(ctx) - - // Write JSON response to plugin memory - resp := StorageGetStoragePathResponse{ - Result: result, - } - storageWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// storageWriteResponse writes a JSON response to plugin memory. -func storageWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - storageWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// storageWriteError writes an error response to plugin memory. -func storageWriteError(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.go b/plugins/host_artwork.go index 7a19eddb9..49b9a285d 100644 --- a/plugins/host_artwork.go +++ b/plugins/host_artwork.go @@ -14,24 +14,24 @@ func newArtworkService() host.ArtworkService { return &artworkServiceImpl{} } -func (a *artworkServiceImpl) GetArtistUrl(ctx context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: id} - return publicurl.ImageURL(ctx, artID, int(size)), nil + return publicurl.ImageURL(nil, artID, int(size)), nil } -func (a *artworkServiceImpl) GetAlbumUrl(ctx context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: id} - return publicurl.ImageURL(ctx, artID, int(size)), nil + return publicurl.ImageURL(nil, artID, int(size)), nil } -func (a *artworkServiceImpl) GetTrackUrl(ctx context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: id} - return publicurl.ImageURL(ctx, artID, int(size)), nil + return publicurl.ImageURL(nil, artID, int(size)), nil } -func (a *artworkServiceImpl) GetPlaylistUrl(ctx context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetPlaylistUrl(_ context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindPlaylistArtwork, ID: id} - return publicurl.ImageURL(ctx, artID, int(size)), nil + return publicurl.ImageURL(nil, artID, int(size)), nil } var _ host.ArtworkService = (*artworkServiceImpl)(nil) diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 311bf21d5..151a0d03c 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -14,7 +16,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "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" @@ -46,7 +47,7 @@ var _ = Describe("ArtworkService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false // Initialize auth (required for token generation) @@ -121,7 +122,7 @@ var _ = Describe("ArtworkService", Ordered, func() { Size: size, } inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.CallWithContext(ctx, "nd_test_artwork", inputBytes) + _, outputBytes, err := instance.Call("nd_test_artwork", inputBytes) if err != nil { return "", err } @@ -188,14 +189,6 @@ var _ = Describe("ArtworkService", Ordered, func() { Expect(artID.ID).To(Equal("al-456")) }) - It("uses the address of the request that triggered the plugin", func() { - ctx := request.WithServerAddress(GinkgoT().Context(), "https", "music.example.com") - - url, err := callTestArtwork(ctx, "track", "mf-789", 300) - Expect(err).ToNot(HaveOccurred()) - Expect(url).To(HavePrefix("https://music.example.com/share/img/")) - }) - It("should handle unknown artwork type", func() { _, err := callTestArtwork(GinkgoT().Context(), "unknown", "id-123", 0) Expect(err).To(HaveOccurred()) @@ -232,7 +225,7 @@ func decodeArtworkURL(artworkURL string) model.ArtworkID { } // Decode JWT token - token, err := auth.PublicTokenAuth.Decode(tokenPart) + token, err := auth.TokenAuth.Decode(tokenPart) Expect(err).ToNot(HaveOccurred(), "Failed to decode JWT token") c := auth.ClaimsFromToken(token) diff --git a/plugins/host_cache.go b/plugins/host_cache.go index f410dfd3d..b90d790cf 100644 --- a/plugins/host_cache.go +++ b/plugins/host_cache.go @@ -2,7 +2,6 @@ package plugins import ( "context" - "runtime" "time" "github.com/jellydator/ttlcache/v3" @@ -30,18 +29,11 @@ func newCacheService(pluginName string) *cacheServiceImpl { // Start the janitor goroutine to clean up expired entries go cache.Start() - svc := &cacheServiceImpl{ + return &cacheServiceImpl{ pluginName: pluginName, cache: cache, defaultTTL: defaultCacheTTL, } - - // Automatic cleanup to prevent goroutine leak when the service is garbage collected - runtime.AddCleanup(svc, func(ttlCache *ttlcache.Cache[string, any]) { - ttlCache.Stop() - }, cache) - - return svc } // getTTL converts seconds to a duration, using default if 0 or negative diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index 925856701..0f55bcfda 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -6,11 +8,9 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" "net/http" "os" "path/filepath" - "runtime" "time" "github.com/navidrome/navidrome/conf" @@ -343,7 +343,7 @@ var _ = Describe("CacheService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin @@ -599,35 +599,3 @@ var _ = Describe("CacheService Integration", Ordered, func() { }) }) }) - -var _ = Describe("newCacheService", func() { - // The suite above leaves goroutines winding down, so settle before sampling. - settledBaseline := func() int { - var n int - Eventually(func() int { - runtime.GC() - prev := n - n = runtime.NumGoroutine() - return n - prev - }).WithTimeout(10 * time.Second).WithPolling(20 * time.Millisecond).Should(BeZero()) - return n - } - - It("stops the janitor goroutine once the service is unreachable", func() { - const numServices = 5 - baseline := settledBaseline() - - func() { - services := make([]*cacheServiceImpl, 0, numServices) - for i := range numServices { - services = append(services, newCacheService(fmt.Sprintf("plugin_%d", i))) - } - Expect(runtime.NumGoroutine()).To(BeNumerically(">=", baseline+numServices), - "expected one janitor goroutine per cache service") - }() - - Eventually(func() int { runtime.GC(); return runtime.NumGoroutine() }). - WithTimeout(10*time.Second).WithPolling(20*time.Millisecond). - Should(BeNumerically("<=", baseline), "janitor goroutines leaked") - }) -}) diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go index 80b06efe1..bd3368a67 100644 --- a/plugins/host_config_test.go +++ b/plugins/host_config_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -55,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 = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false // Setup mock DataStore diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index 6a9a4d2d6..f1d64deb7 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -10,13 +10,10 @@ import ( "net/http" "net/url" "strings" - "syscall" "time" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/utils/httpclient" - "github.com/navidrome/navidrome/utils/netguard" ) const ( @@ -36,7 +33,6 @@ type httpServiceImpl struct { pluginName string requiredHosts []string client *http.Client - transport *http.Transport } // newHTTPService creates a new HTTPService for a plugin. @@ -49,38 +45,28 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI pluginName: pluginName, requiredHosts: requiredHosts, } - svc.transport = http.DefaultTransport.(*http.Transport).Clone() - svc.transport.DialContext = (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - Control: svc.dialControl, - Resolver: dialResolver, - }).DialContext - // No client timeout: it is set per-request via context deadline. - svc.client = &http.Client{Transport: httpclient.NewTransport(svc.transport)} - svc.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if req.Context().Value(noFollowRedirectsKey) != nil { - return http.ErrUseLastResponse - } - if len(via) >= httpClientMaxRedirects { - log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) - return http.ErrUseLastResponse - } - if err := svc.validateHost(req.Context(), req.URL.Host); err != nil { - log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err) - return err - } - return nil + svc.client = &http.Client{ + Transport: http.DefaultTransport, + // Timeout is set per-request via context deadline, not here. + // CheckRedirect validates hosts and enforces redirect limits. + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.Context().Value(noFollowRedirectsKey) != nil { + return http.ErrUseLastResponse + } + if len(via) >= httpClientMaxRedirects { + log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) + return http.ErrUseLastResponse + } + if err := svc.validateHost(req.Context(), req.URL.Host); err != nil { + log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err) + return err + } + return nil + }, } return svc } -// Close releases the plugin's pooled connections when the plugin is unloaded. -func (s *httpServiceImpl) Close() error { - s.transport.CloseIdleConnections() - return nil -} - func (s *httpServiceImpl) Send(ctx context.Context, request host.HTTPRequest) (*host.HTTPResponse, error) { // Parse and validate URL parsedURL, err := url.Parse(request.URL) @@ -161,7 +147,7 @@ func (s *httpServiceImpl) validateHost(ctx context.Context, hostStr string) erro hostname := extractHostname(hostStr) if len(s.requiredHosts) > 0 { - if !isHostInAllowlist(s.requiredHosts, hostname) { + if !s.isHostAllowed(hostname) { return fmt.Errorf("host %q is not allowed", hostStr) } return nil @@ -175,8 +161,13 @@ func (s *httpServiceImpl) validateHost(ctx context.Context, hostStr string) erro return nil } -func (s *httpServiceImpl) dialControl(_, address string, _ syscall.RawConn) error { - return checkPrivateDial(s.requiredHosts, address) +func (s *httpServiceImpl) isHostAllowed(hostname string) bool { + for _, pattern := range s.requiredHosts { + if matchHostPattern(pattern, hostname) { + return true + } + } + return false } // extractHostname returns the hostname portion of a host string, stripping @@ -193,8 +184,11 @@ func extractHostname(hostStr string) string { return hostStr } -// isPrivateOrLoopback is a pre-flight check on the literal host (IP or "localhost"); it does not -// resolve names, so dialControl remains the real guard. +// isPrivateOrLoopback returns true if the given hostname resolves to or is +// a private, loopback, or link-local IP address. This includes: +// IPv4: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 +// IPv6: ::1, fc00::/7, fe80::/10 +// It also blocks "localhost" by name. func isPrivateOrLoopback(hostname string) bool { if strings.EqualFold(hostname, "localhost") { return true @@ -203,7 +197,7 @@ func isPrivateOrLoopback(hostname string) bool { if ip == nil { return false } - return netguard.IsPrivateIP(ip) + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() } // Verify interface implementation diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go index 81e3192fd..27e92d59d 100644 --- a/plugins/host_httpclient_test.go +++ b/plugins/host_httpclient_test.go @@ -1,9 +1,10 @@ +//go:build !windows + package plugins import ( "context" "io" - "net" "net/http" "net/http/httptest" "strings" @@ -20,10 +21,6 @@ var _ = Describe("httpServiceImpl", func() { ts *httptest.Server ) - BeforeEach(func() { - stubLocalhostDNS() - }) - AfterEach(func() { if ts != nil { ts.Close() @@ -48,21 +45,6 @@ var _ = Describe("httpServiceImpl", func() { Expect(err.Error()).To(ContainSubstring("private/loopback")) }) - It("should block a symbolic hostname that resolves to loopback (SSRF)", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - })) - // The trailing dot passes the pre-flight string check; only the dial-time guard catches it. - _, port, _ := net.SplitHostPort(strings.TrimPrefix(ts.URL, "http://")) - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "http://localhost.:" + port + "/test", - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - It("should block requests to localhost by name", func() { _, err := svc.Send(context.Background(), host.HTTPRequest{ Method: "GET", @@ -439,53 +421,13 @@ var _ = Describe("httpServiceImpl", func() { Expect(resp).To(BeNil()) }) - It("blocks a private IP reached via a hostname allowlist entry (rebinding protection)", func() { - // Allowlisting a name authorizes the external service, not whatever private - // IP it may resolve or rebind to. Only literal IP/CIDR entries do that. - svc.requiredHosts = []string{"api.example.com"} - err := svc.dialControl("tcp", "10.0.0.1:80", nil) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - - It("allows a private IP that an explicit CIDR allowlist entry authorizes", func() { - svc.requiredHosts = []string{"10.0.0.0/8"} - Expect(svc.dialControl("tcp", "10.0.0.1:80", nil)).To(Succeed()) - }) - - It("allows private IPs when the allowlist is the bare '*' wildcard", func() { - svc.requiredHosts = []string{"*"} - Expect(svc.dialControl("tcp", "192.168.1.10:8000", nil)).To(Succeed()) - Expect(svc.dialControl("tcp", "127.0.0.1:8000", nil)).To(Succeed()) - }) - - It("still blocks private IPs for a subdomain wildcard entry", func() { - svc.requiredHosts = []string{"*.example.com"} - Expect(svc.dialControl("tcp", "10.0.0.1:80", nil)).To(MatchError(ContainSubstring("private/loopback"))) - }) - - It("closes idle pooled connections on Close", func() { - closed := make(chan struct{}) - ts = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - ts.Config.ConnState = func(_ net.Conn, state http.ConnState) { - if state == http.StateClosed { - close(closed) - } - } - ts.Start() - svc.requiredHosts = []string{"127.0.0.1"} - _, err := svc.Send(context.Background(), host.HTTPRequest{Method: "GET", URL: ts.URL, TimeoutMs: 1000}) - Expect(err).ToNot(HaveOccurred()) - Expect(svc.Close()).To(Succeed()) - Eventually(closed).Should(BeClosed()) - }) - It("should allow wildcard host patterns", func() { ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("wildcard")) })) - // The literal IP is what authorizes the loopback dial under the private-IP guard. - svc.requiredHosts = []string{"*.0.0.1", "127.0.0.1"} + // *.allowed.org is in the requiredHosts from BeforeEach, but test server is 127.0.0.1 + // Override with a wildcard that matches the test server + svc.requiredHosts = []string{"*.0.0.1"} resp, err := svc.Send(context.Background(), host.HTTPRequest{ Method: "GET", URL: ts.URL, @@ -626,11 +568,6 @@ var _ = Describe("isPrivateOrLoopback", func() { Expect(isPrivateOrLoopback("fe80::1")).To(BeTrue()) }) - It("should detect unspecified addresses, which dial the local host", func() { - Expect(isPrivateOrLoopback("0.0.0.0")).To(BeTrue()) - Expect(isPrivateOrLoopback("::")).To(BeTrue()) - }) - It("should allow public IPs", func() { Expect(isPrivateOrLoopback("8.8.8.8")).To(BeFalse()) Expect(isPrivateOrLoopback("203.0.113.1")).To(BeFalse()) diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go index 2224b7485..c3f6ec734 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.String(), "plugins", pluginName) + dataDir := filepath.Join(conf.Server.DataFolder, "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 9af3fcbcd..e5d467f79 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -32,10 +34,11 @@ var _ = Describe("KVStoreService", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(tmpDir) + conf.Server.DataFolder = tmpDir // Create service with 1KB limit for testing - service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) + maxSize := "1KB" + service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) Expect(err).ToNot(HaveOccurred()) }) @@ -250,7 +253,8 @@ var _ = Describe("KVStoreService", func() { // Close and reopen the service (simulating restart) Expect(service.Close()).To(Succeed()) - service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) + maxSize := "1KB" + service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) Expect(err).ToNot(HaveOccurred()) defer service2.Close() @@ -448,7 +452,8 @@ var _ = Describe("KVStoreService", func() { closeCtx, closeCancel := context.WithCancel(ctx) defer closeCancel() - svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: new("1KB")}) + maxSize := "1KB" + svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: &maxSize}) Expect(err).ToNot(HaveOccurred()) // Insert an expired key so cleanup has work to do @@ -700,9 +705,9 @@ var _ = Describe("KVStoreService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.DataFolder = conf.NewDir(tmpDir) + conf.Server.DataFolder = tmpDir // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/host_library.go b/plugins/host_library.go index 25c14a813..3d9f61b4f 100644 --- a/plugins/host_library.go +++ b/plugins/host_library.go @@ -37,7 +37,7 @@ func (s *libraryServiceImpl) GetLibrary(ctx context.Context, id int32) (*host.Li return nil, fmt.Errorf("library not accessible: library ID %d is not in the allowed list", id) } - lib, err := s.ds.Library().Get(ctx, int(id)) + lib, err := s.ds.Library(ctx).Get(int(id)) if err != nil { return nil, fmt.Errorf("library not found: %w", err) } @@ -55,7 +55,7 @@ func (s *libraryServiceImpl) isLibraryAccessible(id int) bool { } func (s *libraryServiceImpl) GetAllLibraries(ctx context.Context) ([]host.Library, error) { - libs, err := s.ds.Library().GetAll(ctx) + libs, err := s.ds.Library(ctx).GetAll() if err != nil { return nil, fmt.Errorf("failed to get libraries: %w", err) } diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index edd4b546f..5746a3bed 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -33,7 +35,8 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetLibrary", func() { It("should return library metadata without filesystem permission", func() { - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) + reason := "test" + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 1, @@ -47,7 +50,7 @@ var _ = Describe("LibraryService", Ordered, func() { } lib.LastScanAt = lib.LastScanAt.Add(0) // Ensure time is set - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{*lib}) result, err := service.GetLibrary(ctx, 1) @@ -64,7 +67,8 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return library metadata with filesystem permission", func() { - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) + reason := "test" + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 2, @@ -77,7 +81,7 @@ var _ = Describe("LibraryService", Ordered, func() { TotalDuration: 1800.0, } - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{*lib}) result, err := service.GetLibrary(ctx, 2) @@ -89,9 +93,10 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return error for non-existent library", func() { - service = newLibraryService(ds, &LibraryPermission{Reason: new("test")}, nil, true).(*libraryServiceImpl) + reason := "test" + service = newLibraryService(ds, &LibraryPermission{Reason: &reason}, nil, true).(*libraryServiceImpl) - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{}) _, err := service.GetLibrary(ctx, 999) @@ -102,14 +107,15 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetAllLibraries", func() { It("should return all libraries without filesystem permission", func() { - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) + reason := "test" + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) results, err := service.GetAllLibraries(ctx) @@ -124,14 +130,15 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return all libraries with filesystem permission", func() { - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) + reason := "test" + service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) results, err := service.GetAllLibraries(ctx) @@ -145,8 +152,10 @@ var _ = Describe("LibraryService", Ordered, func() { }) Describe("Library Access Filtering", func() { - 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) + 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) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -154,7 +163,7 @@ var _ = Describe("LibraryService", Ordered, func() { {ID: 3, Name: "Classical", Path: "/music/classical", TotalSongs: 75}, } - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) results, err := service.GetAllLibraries(ctx) @@ -164,15 +173,17 @@ 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() { // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + 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) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) // Requesting library 1 which is not in the allowed list @@ -181,15 +192,17 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(err.Error()).To(ContainSubstring("not accessible")) }) - 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) + 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) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) result, err := service.GetLibrary(ctx, 2) @@ -198,15 +211,17 @@ 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() { // No libraries allowed - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{}, false).(*libraryServiceImpl) + 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) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) results, err := service.GetAllLibraries(ctx) @@ -214,15 +229,17 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(results).To(HaveLen(0)) }) - 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) + 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) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, } - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(libs) results, err := service.GetAllLibraries(ctx) @@ -246,7 +263,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 = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = tmpDir // Create mock &tests.MockLibraryRepo{} mockLibRepo := &tests.MockLibraryRepo{} @@ -291,7 +308,7 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(manager.ds).ToNot(BeNil()) ctx := context.Background() - libs, err := manager.ds.Library().GetAll(adminContext(ctx)) + libs, err := manager.ds.Library(adminContext(ctx)).GetAll() Expect(err).ToNot(HaveOccurred()) Expect(libs).To(HaveLen(1)) Expect(libs[0].Path).To(Equal("/tmp/test-music")) @@ -340,7 +357,7 @@ var _ = Describe("LibraryService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = 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 deleted file mode 100644 index 125d16649..000000000 --- a/plugins/host_matcher.go +++ /dev/null @@ -1,198 +0,0 @@ -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 deleted file mode 100644 index 7d3e47fae..000000000 --- a/plugins/host_matcher_test.go +++ /dev/null @@ -1,512 +0,0 @@ -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(GinkgoT().Context(), &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(GinkgoT().Context(), &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 the media file queries run with, so tests -// can assert which user the matcher resolved before querying the library. -type ctxCapturingDataStore struct { - *tests.MockDataStore - repo *ctxCapturingMediaFileRepo -} - -func (d *ctxCapturingDataStore) MediaFile() model.MediaFileRepository { - if d.repo == nil { - d.repo = &ctxCapturingMediaFileRepo{MediaFileRepository: d.MockDataStore.MediaFile()} - } - return d.repo -} - -func (d *ctxCapturingDataStore) lastMediaFileCtx() context.Context { - if d.repo == nil { - return nil - } - return d.repo.lastCtx -} - -type ctxCapturingMediaFileRepo struct { - model.MediaFileRepository - lastCtx context.Context -} - -func (r *ctxCapturingMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { - r.lastCtx = ctx - return r.MediaFileRepository.GetAll(ctx, options...) -} - -func (r *ctxCapturingMediaFileRepo) GetAllByTags(ctx context.Context, tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { - r.lastCtx = ctx - return r.MediaFileRepository.GetAllByTags(ctx, tag, values, options...) -} diff --git a/plugins/host_netguard.go b/plugins/host_netguard.go deleted file mode 100644 index ab092009a..000000000 --- a/plugins/host_netguard.go +++ /dev/null @@ -1,58 +0,0 @@ -package plugins - -import ( - "fmt" - "net" - "slices" - - "github.com/navidrome/navidrome/utils/netguard" -) - -// dialResolver is nil in production (the system resolver); tests swap in a stub to avoid real DNS. -var dialResolver *net.Resolver - -// checkPrivateDial runs at dial time on the resolved IP, so hostnames can't reach private addresses unless a -// literal IP/CIDR entry or a bare "*" (plugins targeting user-configured LAN services) allows it. -func checkPrivateDial(requiredHosts []string, address string) error { - if slices.Contains(requiredHosts, "*") { - return nil - } - host, _, err := net.SplitHostPort(address) - if err != nil { - return err - } - ip := net.ParseIP(host) - if ip == nil || !netguard.IsPrivateIP(ip) { - return nil - } - for _, entry := range requiredHosts { - if ipMatchesEntry(entry, ip) { - return nil - } - } - return fmt.Errorf("dial to private/loopback address %q blocked: requires an explicit IP or CIDR in requiredHosts", address) -} - -func isHostInAllowlist(requiredHosts []string, hostname string) bool { - ip := net.ParseIP(hostname) - for _, pattern := range requiredHosts { - if matchHostPattern(pattern, hostname) { - return true - } - if ip != nil && ipMatchesEntry(pattern, ip) { - return true - } - } - return false -} - -// ipMatchesEntry reports whether a requiredHosts entry is a literal IP or CIDR that covers ip. -func ipMatchesEntry(entry string, ip net.IP) bool { - if _, cidr, err := net.ParseCIDR(entry); err == nil { - return cidr.Contains(ip) - } - if entryIP := net.ParseIP(entry); entryIP != nil { - return entryIP.Equal(ip) - } - return false -} diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index 6940d8820..334d9b738 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -49,7 +51,7 @@ var _ = Describe("SchedulerService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false // Create mock scheduler and timer registry diff --git a/plugins/host_scrobbleretriever.go b/plugins/host_scrobbleretriever.go deleted file mode 100644 index 780b0274d..000000000 --- a/plugins/host_scrobbleretriever.go +++ /dev/null @@ -1,161 +0,0 @@ -package plugins - -import ( - "context" - "fmt" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/plugins/host" -) - -// maxScrobbleItems caps how many scrobbles a single GetScrobbles call can return. -const maxScrobbleItems = 5000 - -type scrobbleRetrieverServiceImpl struct { - ds model.DataStore - users userAccess -} - -func newScrobbleRetrieverService(ds model.DataStore, users userAccess) host.ScrobbleRetrieverService { - return &scrobbleRetrieverServiceImpl{ - ds: ds, - users: users, - } -} - -func (s *scrobbleRetrieverServiceImpl) getUserContext(ctx context.Context, username string) (context.Context, error) { - usr, err := s.users.resolve(ctx, s.ds, username) - if err != nil { - return nil, fmt.Errorf("scrobbleRetriever: %w", err) - } - - ctx = request.WithUser(ctx, *usr) - return ctx, nil -} - -func (s *scrobbleRetrieverServiceImpl) getFirstLastScrobble(ctx context.Context, username string, order string) (*int64, error) { - ctx, err := s.getUserContext(ctx, username) - if err != nil { - return nil, err - } - - scrobbles, err := s.ds.Scrobble().GetAll(ctx, model.QueryOptions{Sort: "submission_time", Order: order, Max: 1}) - if err != nil { - return nil, err - } - - if len(scrobbles) == 0 { - return nil, nil - } - - return &scrobbles[0].SubmissionTime, nil -} - -func (s *scrobbleRetrieverServiceImpl) GetFirstTimestamp(ctx context.Context, username string) (*int64, error) { - return s.getFirstLastScrobble(ctx, username, "ASC") -} - -func (s *scrobbleRetrieverServiceImpl) GetLastTimestamp(ctx context.Context, username string) (*int64, error) { - return s.getFirstLastScrobble(ctx, username, "DESC") -} - -func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, username string, options host.ScrobbleOptions) ([]host.ScrobbleRef, *host.ScrobbleOptions, error) { - ctx, err := s.getUserContext(ctx, username) - if err != nil { - return nil, nil, err - } - - if options.MaxItems < 1 || options.MaxItems > maxScrobbleItems { - options.MaxItems = maxScrobbleItems - } - options.Offset = max(options.Offset, 0) - - order := "ASC" - if options.Descending { - order = "DESC" - } - - // Fetch one more item than requested. The last item is the next timestamp to fetch - lookahead := options.MaxItems + 1 - - scrobbles, err := s.ds.Scrobble().GetAll(ctx, model.QueryOptions{ - Max: lookahead, - Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), - // The id tiebreak makes the order of equal timestamps stable, which is what - // lets Offset skip exactly the ties already returned - Sort: "scrobbles.submission_time, scrobbles.id", - Order: order, - Offset: options.Offset, - }) - if err != nil { - return nil, nil, err - } - - var next *host.ScrobbleOptions - targetLen := len(scrobbles) - - if len(scrobbles) == lookahead { - nextTimestamp := scrobbles[lookahead-1].SubmissionTime - targetLen = lookahead - 1 - - ties := 0 - for i := targetLen - 1; i >= 0; i-- { - if scrobbles[i].SubmissionTime != nextTimestamp { - break - } - ties++ - } - - // Every scrobble in this page shares the timestamp, so the ties skipped by the - // incoming offset are still ahead of us and must be carried over - if ties == targetLen { - ties += options.Offset - } - - advanced := options - advanced.Offset = ties - if options.Descending { - advanced.ToTimestamp = &nextTimestamp - } else { - advanced.FromTimestamp = &nextTimestamp - } - next = &advanced - } - - scrobbleRefs := make([]host.ScrobbleRef, targetLen) - for idx, scrobble := range scrobbles[:targetLen] { - scrobbleRefs[idx] = host.ScrobbleRef{ - ID: scrobble.ID, - MediaFileID: scrobble.MediaFileID, - SubmissionTime: scrobble.SubmissionTime, - } - } - - return scrobbleRefs, next, nil -} - -func (s *scrobbleRetrieverServiceImpl) GetScrobbleCount(ctx context.Context, username string, options host.ScrobbleCountOptions) (int64, error) { - ctx, err := s.getUserContext(ctx, username) - if err != nil { - return 0, err - } - - return s.ds.Scrobble().CountAll(ctx, model.QueryOptions{ - Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), - }) -} - -func scrobbleRangeFilters(from, to *int64) squirrel.And { - var filters squirrel.And - if from != nil { - filters = append(filters, squirrel.GtOrEq{"scrobbles.submission_time": *from}) - } - if to != nil { - filters = append(filters, squirrel.LtOrEq{"scrobbles.submission_time": *to}) - } - return filters -} - -var _ host.ScrobbleRetrieverService = (*scrobbleRetrieverServiceImpl)(nil) diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go deleted file mode 100644 index 6721c1487..000000000 --- a/plugins/host_scrobbleretriever_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package plugins - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "strconv" - "time" - - extism "github.com/extism/go-sdk" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// scrobblePage mirrors what the test plugin emits for the multi-return GetScrobbles -type scrobblePage struct { - Scrobbles []host.ScrobbleRef `json:"scrobbles"` - Next *host.ScrobbleOptions `json:"next"` -} - -var _ = Describe("Scrobble Retriever Host Function", Ordered, func() { - var ( - manager *Manager - tmpDir string - dataStore *tests.MockDataStore - ) - - p := func(val int64) *int64 { - return &val - } - - opts := func(from, to *int64, descending bool, offset, maxItems int) *host.ScrobbleOptions { - return &host.ScrobbleOptions{ - FromTimestamp: from, ToTimestamp: to, - Descending: descending, Offset: offset, MaxItems: maxItems, - } - } - - BeforeAll(func() { - ctx := GinkgoT().Context() - - var err error - tmpDir, err = os.MkdirTemp("", "scrobble-retriever-test-*") - Expect(err).ToNot(HaveOccurred()) - - conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") - - db.Init(ctx) - DeferCleanup(func() { - Expect(tests.ClearDB()).To(Succeed()) - }) - dataStore = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - - // Copy test plugin to temp dir - srcPath := filepath.Join(testdataDir, "test-scrobble-retriever"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - userRepo := dataStore.User() - // Add test users - _ = userRepo.Put(ctx, &model.User{ - ID: "user1", - UserName: "testuser", - IsAdmin: false, - }) - _ = userRepo.Put(ctx, &model.User{ - ID: "admin1", - UserName: "adminuser", - IsAdmin: true, - }) - - err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "1", LibraryID: 1}) - Expect(err).To(BeNil()) - err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "2", LibraryID: 1}) - Expect(err).To(BeNil()) - err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "3", LibraryID: 1}) - Expect(err).To(BeNil()) - - scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) - - scrobbleRepo := dataStore.Scrobble() - err = scrobbleRepo.RecordScrobble(scrobbleCtx, "1", time.Unix(0, 0)) - Expect(err).To(BeNil()) - err = scrobbleRepo.RecordScrobble(scrobbleCtx, "2", time.Unix(1, 0)) - Expect(err).To(BeNil()) - err = scrobbleRepo.RecordScrobble(scrobbleCtx, "3", time.Unix(2, 0)) - Expect(err).To(BeNil()) - err = scrobbleRepo.RecordScrobble(scrobbleCtx, "1", time.Unix(2, 0)) - Expect(err).To(BeNil()) - - // Create and configure manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - } - router := &fakeSubsonicRouter{} - manager.SetSubsonicRouter(router) - - // Pre-enable the plugin in the mock repo so it loads on startup - // Compute SHA256 of the plugin file to match what syncPlugins will compute - pluginPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) - wasmData, err := os.ReadFile(pluginPath) - Expect(err).ToNot(HaveOccurred()) - hash := sha256.Sum256(wasmData) - hashHex := hex.EncodeToString(hash[:]) - - dataStore.MockedPlugin = tests.CreateMockPluginRepo() - - mockPluginRepo := dataStore.Plugin().(*tests.MockPluginRepo) - mockPluginRepo.Permitted = true - enabledPlugin := model.Plugin{ - ID: "test-scrobble-retriever", - Path: pluginPath, - SHA256: hashHex, - Enabled: true, - Users: `["user1","admin1"]`, - } - mockPluginRepo.SetData(model.Plugins{enabledPlugin}) - - // Start the manager - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - var instance *extism.Plugin - - BeforeEach(func() { - manager.mu.RLock() - plugin := manager.plugins["test-scrobble-retriever"] - manager.mu.RUnlock() - Expect(plugin).ToNot(BeNil()) - - var err error - ctx := GinkgoT().Context() - instance, err = plugin.instance(ctx) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - instance.Close(ctx) - }) - }) - - Describe("not authorized", func() { - It("rejects first timestamp", func() { - exit, _, err := instance.Call("call_get_first_timestamp", []byte("baduser")) - Expect(err).To(HaveOccurred()) - Expect(exit).To(Equal(uint32(1))) - }) - - It("rejects last timestamp", func() { - exit, _, err := instance.Call("call_get_last_timestamp", []byte("baduser")) - Expect(err).To(HaveOccurred()) - Expect(exit).To(Equal(uint32(1))) - }) - - It("rejects scrobbles", func() { - exit, _, err := instance.Call("call_get_scrobbles", []byte(`{"username":"baduser"}`)) - Expect(err).To(HaveOccurred()) - Expect(exit).To(Equal(uint32(1))) - }) - - It("rejects scrobbles", func() { - exit, _, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"baduser"}`)) - Expect(err).To(HaveOccurred()) - Expect(exit).To(Equal(uint32(1))) - }) - }) - - Describe("no items", func() { - It("calls get first timestamp", func() { - exit, output, err := instance.Call("call_get_first_timestamp", []byte("testuser")) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) - }) - - It("calls get last timestamp", func() { - exit, output, err := instance.Call("call_get_last_timestamp", []byte("testuser")) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) - }) - - It("calls scrobbles", func() { - exit, output, err := instance.Call("call_get_scrobbles", []byte(`{"username":"testuser"}`)) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - Expect(output).To(Equal([]byte(`{"scrobbles":[],"next":null}`))) - }) - - It("calls get scrobble count", func() { - exit, output, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"testuser"}`)) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - Expect(output).To(Equal([]byte("0"))) - }) - }) - - Describe("with items", func() { - scrobbles := []host.ScrobbleRef{ - {ID: 1, MediaFileID: "1", SubmissionTime: 0}, - {ID: 2, MediaFileID: "2", SubmissionTime: 1}, - {ID: 3, MediaFileID: "3", SubmissionTime: 2}, - {ID: 4, MediaFileID: "1", SubmissionTime: 2}, - } - - scrobblesReversed := make([]host.ScrobbleRef, 4) - - BeforeAll(func() { - for idx := range scrobbles { - scrobblesReversed[3-idx] = scrobbles[idx] - } - }) - - It("calls get first timestamp", func() { - exit, output, err := instance.Call("call_get_first_timestamp", []byte("adminuser")) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - Expect(output).To(Equal([]byte("{\"timestamp\":0}"))) - }) - - It("calls get last timestamp", func() { - exit, output, err := instance.Call("call_get_last_timestamp", []byte("adminuser")) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - Expect(output).To(Equal([]byte("{\"timestamp\":2}"))) - }) - - DescribeTable("getScrobbles", func(params string, scrobbles []host.ScrobbleRef, next *host.ScrobbleOptions) { - exit, output, err := instance.Call("call_get_scrobbles", []byte(params)) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - var page scrobblePage - Expect(json.Unmarshal(output, &page)).To(Succeed()) - Expect(page).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next})) - }, - Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil), - Entry("calls scrobbles in descending order by request", `{"username":"adminuser","descending":true}`, scrobblesReversed, nil), - Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil), - Entry("defaults to ascending even when only toTimestamp is given", `{"username":"adminuser","toTimestamp":2}`, scrobbles, nil), - Entry("calls subset of scrobbles in ascending order, next page", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], opts(p(2), nil, false, 0, 2)), - Entry("calls subset of scrobbles in ascending order, next page with offset", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], opts(p(2), nil, false, 1, 2)), - Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil), - Entry("calls subset of scrobbles in descending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1,"descending":true}`, scrobblesReversed[:3], nil), - Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2,"descending":true}`, scrobblesReversed, nil), - Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 3}`, scrobblesReversed[:3], opts(nil, p(0), true, 0, 3)), - Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 1}`, scrobblesReversed[:1], opts(nil, p(2), true, 1, 1)), - ) - - DescribeTable("GetScrobblesCount", func(params string, count int) { - exit, output, err := instance.Call("call_get_scrobbles_count", []byte(params)) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - value, err := strconv.ParseInt(string(output), 10, 64) - Expect(err).ToNot(HaveOccurred()) - Expect(value).To(Equal(int64(count))) - }, - Entry("gets all scrobbles", `{"username":"adminuser"}`, 4), - Entry("gets two scrobbles ascending", `{"username":"adminuser", "fromTimestamp": 2}`, 2), - Entry("gets one scrobble descending", `{"username":"adminuser", "toTimestamp": 0}`, 1), - Entry("filters upper and bottom", `{"username":"adminuser", "fromTimestamp": 1, "toTimestamp": 1}`, 1), - Entry("accepts filter out of range", `{"username":"adminuser", "fromTimestamp": -1, "toTimestamp": 1000}`, 4), - ) - }) - - Context("Complex edge cases - multiple scrobbles at the same timestamp", func() { - duplicates := make([]host.ScrobbleRef, 5) - duplicatesReversed := make([]host.ScrobbleRef, 5) - - BeforeAll(func() { - scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) - - scrobbleRepo := dataStore.Scrobble() - - for i := range 5 { - err := scrobbleRepo.RecordScrobble(scrobbleCtx, "3", time.Unix(100, 0)) - Expect(err).To(BeNil()) - - scrobble := host.ScrobbleRef{ID: 5 + int64(i), MediaFileID: "3", SubmissionTime: 100} - duplicates[i] = scrobble - duplicatesReversed[4-i] = scrobble - } - }) - - getPage := func(o host.ScrobbleOptions) scrobblePage { - GinkgoHelper() - payload, err := json.Marshal(struct { - Username string `json:"username"` - FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - ToTimestamp *int64 `json:"toTimestamp,omitempty"` - Descending bool `json:"descending"` - Offset int `json:"offset,omitempty"` - MaxItems int `json:"maxItems"` - }{ - Username: "adminuser", - FromTimestamp: o.FromTimestamp, - ToTimestamp: o.ToTimestamp, - Descending: o.Descending, - Offset: o.Offset, - MaxItems: o.MaxItems, - }) - Expect(err).ToNot(HaveOccurred()) - - exit, output, err := instance.Call("call_get_scrobbles", payload) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - var page scrobblePage - Expect(json.Unmarshal(output, &page)).To(Succeed()) - return page - } - - // every scrobble here shares timestamp 100, so only Offset advances between pages - tied := func(offset, maxItems int, descending bool) host.ScrobbleOptions { - return *opts(p(100), p(100), descending, offset, maxItems) - } - - DescribeTable("Edge cases; duplicate scrobbles", func(offset, count int, descending bool, scrobbles []host.ScrobbleRef, nextOffset *int) { - var next *host.ScrobbleOptions - if nextOffset != nil { - next = opts(p(100), p(100), descending, *nextOffset, count) - } - Expect(getPage(tied(offset, count, descending))).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next})) - }, - Entry("All tracks, in order", 0, 100, false, duplicates, nil), - Entry("All tracks, in reverse order", 0, 100, true, duplicatesReversed, nil), - Entry("Ascending order, from the start", 0, 1, false, duplicates[:1], new(1)), - Entry("Ascending order, middle page", 1, 2, false, duplicates[1:3], new(3)), - Entry("Ascending order, to the end", 3, 100, false, duplicates[3:], nil), - Entry("Ascending order, from the start, continuing offset", 3, 1, false, duplicates[3:4], new(4)), - Entry("start descending", 0, 2, true, duplicatesReversed[:2], new(2)), - Entry("start descending, next step", 2, 2, true, duplicatesReversed[2:4], new(4)), - Entry("start descending, end", 4, 1, true, duplicatesReversed[4:], nil), - ) - - It("walks every page exactly once when all timestamps collide", func() { - for _, descending := range []bool{false, true} { - var seen []host.ScrobbleRef - o := tied(0, 2, descending) - var page scrobblePage - for range 10 { - page = getPage(o) - seen = append(seen, page.Scrobbles...) - if page.Next == nil { - break - } - o = *page.Next - } - Expect(page.Next).To(BeNil(), "pagination did not terminate") - if descending { - Expect(seen).To(Equal(duplicatesReversed)) - } else { - Expect(seen).To(Equal(duplicates)) - } - } - }) - }) -}) diff --git a/plugins/host_storage.go b/plugins/host_storage.go deleted file mode 100644 index e2ead7aae..000000000 --- a/plugins/host_storage.go +++ /dev/null @@ -1,34 +0,0 @@ -package plugins - -import ( - "context" - "fmt" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/plugins/host" -) - -const storageMount = "/storage" - -type storageServiceImpl struct{} - -func getHostStoragePath(pluginName string) string { - return filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName, "storage") -} - -func newStorageService(pluginName string) (host.StorageService, error) { - dataDir := getHostStoragePath(pluginName) - if err := os.MkdirAll(dataDir, 0700); err != nil { - return nil, fmt.Errorf("creating plugin data directory: %w", err) - } - - return &storageServiceImpl{}, nil -} - -func (s *storageServiceImpl) GetStoragePath(ctx context.Context) string { - return storageMount -} - -var _ host.StorageService = (*storageServiceImpl)(nil) diff --git a/plugins/host_storage_test.go b/plugins/host_storage_test.go deleted file mode 100644 index 9d8df23a0..000000000 --- a/plugins/host_storage_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "os" - "path/filepath" - - extism "github.com/extism/go-sdk" - "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("utility functions", Ordered, func() { - var tmpDir string - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "storage-test-*") - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(tmpDir) - - DeferCleanup(func() { - _ = os.RemoveAll(tmpDir) - }) - }) - - Describe("GetHostStoragePath", func() { - It("should join data folder, plugins, plugin name, and storage", func() { - actual := getHostStoragePath("plugin-name") - expected := filepath.Join(tmpDir, "plugins", "plugin-name", "storage") - Expect(actual).To(Equal(expected)) - }) - }) - - Describe("GetStoragePath", func() { - It("should return the fixed path", func() { - impl := storageServiceImpl{} - Expect(impl.GetStoragePath(context.TODO())).To(Equal("/storage")) - }) - }) - - Describe("netStorageService", func() { - It("should create the directory on init", func() { - svc, err := newStorageService("plugin-name") - Expect(err).ToNot(HaveOccurred()) - Expect(svc).ToNot(BeNil()) - - dataDir := filepath.Join(tmpDir, "plugins", "plugin-name", "storage") - Expect(dataDir).To(BeADirectory()) - }) - }) -}) - -var _ = Describe("Storage Host Function", Ordered, func() { - const ID = "test-storage-plugin" - - var ( - manager *Manager - tmpDir string - ) - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "storage-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Setup mock router and data store - router := &fakeSubsonicRouter{} - userRepo := tests.CreateMockUserRepo() - dataStore := &tests.MockDataStore{MockedUser: userRepo} - - // Create and configure manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - } - manager.SetSubsonicRouter(router) - - mockPluginRepo := dataStore.Plugin().(*tests.MockPluginRepo) - mockPluginRepo.Permitted = true - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(tmpDir) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - pluginPaths := []string{ID, ID + "-2"} - plugins := []model.Plugin{} - - for idx := range pluginPaths { - path := pluginPaths[idx] + PackageExtension - // Copy test plugin to temp dir - srcPath := filepath.Join(testdataDir, path) - destPath := filepath.Join(tmpDir, path) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Pre-enable the plugin in the mock repo so it loads on startup - // Compute SHA256 of the plugin file to match what syncPlugins will compute - pluginPath := filepath.Join(tmpDir, path) - wasmData, err := os.ReadFile(pluginPath) - Expect(err).ToNot(HaveOccurred()) - hash := sha256.Sum256(wasmData) - hashHex := hex.EncodeToString(hash[:]) - - plugins = append(plugins, model.Plugin{ - ID: pluginPaths[idx], - Path: pluginPath, - SHA256: hashHex, - Enabled: true, - AllUsers: true, // Allow all users for test plugin - }) - } - - mockPluginRepo.SetData(plugins) - - // Start the manager - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - var instance *extism.Plugin - BeforeEach(func() { - var err error - manager.mu.RLock() - plugin := manager.plugins[ID] - manager.mu.RUnlock() - Expect(plugin).ToNot(BeNil()) - - ctx := GinkgoT().Context() - instance, err = plugin.instance(ctx) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - instance.Close(ctx) - }) - }) - - Describe("Read", func() { - BeforeAll(func() { - path := filepath.Join(getHostStoragePath(ID), "real") - err := os.WriteFile(path, []byte("1234"), 0600) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = os.Remove(path) - }) - }) - - It("should fail to read missing file", func() { - exit, _, err := instance.Call("call_read", []byte("missing")) - Expect(exit).To(Equal(uint32(1))) - Expect(err).To(HaveOccurred()) - }) - - It("should read an existing file", func() { - exit, output, err := instance.Call("call_read", []byte("real")) - Expect(exit).To(Equal(uint32(0))) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal([]byte("1234"))) - }) - - It("should not escape read", func() { - path := filepath.Join(getHostStoragePath(ID), "..", "outside") - err := os.WriteFile(path, []byte("outside"), 0600) - Expect(err).ToNot(HaveOccurred()) - - exit, _, err := instance.Call("call_read", []byte("../outside")) - Expect(exit).To(Equal(uint32(1))) - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("Write", func() { - BeforeAll(func() { - path := filepath.Join(getHostStoragePath(ID), "real") - err := os.WriteFile(path, []byte("1234"), 0600) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = os.Remove(path) - }) - }) - - It("should fail to write to nested file", func() { - exit, _, err := instance.Call("call_write", []byte(`{"path":"nested/file","contents":"1234"}`)) - Expect(exit).To(Equal(uint32(1))) - Expect(err).To(HaveOccurred()) - }) - - It("should write to a file", func() { - exit, _, err := instance.Call("call_write", []byte(`{"path":"new","contents":"contents"}`)) - Expect(exit).To(Equal(uint32(0))) - Expect(err).ToNot(HaveOccurred()) - - data, err := os.ReadFile(filepath.Join(getHostStoragePath(ID), "new")) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(Equal([]byte("contents"))) - - exit, output, err := instance.Call("call_read", []byte("new")) - Expect(exit).To(Equal(uint32(0))) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal([]byte("contents"))) - }) - - It("should not escape writing a file", func() { - path := filepath.Join(getHostStoragePath(ID), "..", "outside") - err := os.WriteFile(path, []byte("outside"), 0600) - Expect(err).ToNot(HaveOccurred()) - - exit, _, err := instance.Call("call_write", []byte(`{"path":"../new","contents":"contents"}`)) - Expect(exit).To(Equal(uint32(1))) - Expect(err).To(HaveOccurred()) - }) - - It("should have independent storage for multiple plugins", func() { - manager.mu.RLock() - plugin2 := manager.plugins[ID+"-2"] - manager.mu.RUnlock() - - Expect(plugin2).ToNot(BeNil()) - - secondInstance, err := plugin2.instance(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - defer secondInstance.Close(GinkgoT().Context()) - - instances := []*extism.Plugin{instance, secondInstance} - names := []string{ID, ID + "-2"} - - for idx := range instances { - exit, _, err := instances[idx].Call("call_write", fmt.Appendf(nil, `{"path":"new","contents":"%s"}`, names[idx])) - Expect(exit).To(Equal(uint32(0))) - Expect(err).ToNot(HaveOccurred()) - } - - for idx := range names { - data, err := os.ReadFile(filepath.Join(getHostStoragePath(names[idx]), "new")) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(Equal([]byte(names[idx]))) - } - - for idx := range instances { - exit, output, err := instances[idx].Call("call_read", []byte("new")) - Expect(exit).To(Equal(uint32(0))) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal([]byte(names[idx]))) - } - }) - }) -}) diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go index a8ff12140..01a33c039 100644 --- a/plugins/host_subsonicapi.go +++ b/plugins/host_subsonicapi.go @@ -26,19 +26,27 @@ 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 - users userAccess // users this plugin may act as (from DB configuration) + 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{} } // newSubsonicAPIService creates a new SubsonicAPIService for a plugin. -func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, users userAccess) host.SubsonicAPIService { +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{}{} + } return &subsonicAPIServiceImpl{ - pluginID: pluginID, - router: router, - ds: ds, - users: users, + pluginID: pluginID, + router: router, + ds: ds, + allowedUserIDs: allowedUserIDs, + allUsers: allUsers, + userIDMap: userIDMap, } } @@ -128,17 +136,17 @@ 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.users.allUsers { + if s.allUsers { return nil } // Must have at least one allowed user ID configured - if len(s.users.userIDSet) == 0 { + if len(s.allowedUserIDs) == 0 { return fmt.Errorf("no users configured for plugin %s", s.pluginID) } // Look up the user by username to get their ID - usr, err := s.ds.User().FindByUsername(ctx, username) + usr, err := s.ds.User(ctx).FindByUsername(username) if err != nil { if errors.Is(err, model.ErrNotFound) { return fmt.Errorf("username %s not found", username) @@ -147,7 +155,7 @@ func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username } // Check if the user's ID is in the allowed list - if !s.users.allows(usr.ID) { + if _, ok := s.userIDMap[usr.ID]; !ok { 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 0d9c75ee7..607f3a64b 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -1,7 +1,8 @@ +//go:build !windows + package plugins import ( - "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -43,7 +44,7 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false // Setup mock router and data store @@ -52,12 +53,12 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { dataStore = &tests.MockDataStore{MockedUser: userRepo} // Add test users - _ = userRepo.Put(GinkgoT().Context(), &model.User{ + _ = userRepo.Put(&model.User{ ID: "user1", UserName: "testuser", IsAdmin: false, }) - _ = userRepo.Put(GinkgoT().Context(), &model.User{ + _ = userRepo.Put(&model.User{ ID: "admin1", UserName: "adminuser", IsAdmin: true, @@ -78,7 +79,7 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { hash := sha256.Sum256(wasmData) hashHex := hex.EncodeToString(hash[:]) - mockPluginRepo := dataStore.Plugin().(*tests.MockPluginRepo) + mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) mockPluginRepo.Permitted = true enabledPlugin := model.Plugin{ ID: "test-subsonicapi-plugin", @@ -235,29 +236,27 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { var _ = Describe("SubsonicAPIService", func() { var ( - ctx context.Context router *fakeSubsonicRouter userRepo *tests.MockedUserRepo dataStore *tests.MockDataStore ) BeforeEach(func() { - ctx = GinkgoT().Context() router = &fakeSubsonicRouter{} userRepo = tests.CreateMockUserRepo() dataStore = &tests.MockDataStore{MockedUser: userRepo} - _ = userRepo.Put(ctx, &model.User{ + _ = userRepo.Put(&model.User{ ID: "user1", UserName: "testuser", IsAdmin: false, }) - _ = userRepo.Put(ctx, &model.User{ + _ = userRepo.Put(&model.User{ ID: "admin1", UserName: "adminuser", IsAdmin: true, }) - _ = userRepo.Put(ctx, &model.User{ + _ = userRepo.Put(&model.User{ ID: "user2", UserName: "alloweduser", IsAdmin: false, @@ -268,8 +267,9 @@ 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, newUserAccess([]string{"user2"}, false)) + service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not authorized")) @@ -277,8 +277,9 @@ var _ = Describe("SubsonicAPIService", func() { It("allows users in the allowed list", func() { // allowedUserIDs contains "user2" which is "alloweduser" - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) + service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=alloweduser") Expect(err).ToNot(HaveOccurred()) Expect(response).To(ContainSubstring("ok")) @@ -286,8 +287,9 @@ 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, newUserAccess([]string{"user1"}, false)) + service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=adminuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not authorized")) @@ -295,8 +297,9 @@ var _ = Describe("SubsonicAPIService", func() { It("allows admin users when in allowed list", func() { // allowedUserIDs contains "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"admin1"}, false)) + service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"admin1"}, false) + ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") Expect(err).ToNot(HaveOccurred()) Expect(response).To(ContainSubstring("ok")) @@ -305,16 +308,18 @@ var _ = Describe("SubsonicAPIService", func() { Context("with allUsers=true", func() { It("allows all users regardless of allowed list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=testuser") Expect(err).ToNot(HaveOccurred()) Expect(response).To(ContainSubstring("ok")) }) It("allows admin users when allUsers is true", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + ctx := GinkgoT().Context() response, err := service.Call(ctx, "/ping?u=adminuser") Expect(err).ToNot(HaveOccurred()) Expect(response).To(ContainSubstring("ok")) @@ -323,16 +328,18 @@ 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, newUserAccess(nil, false)) + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, false) + ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("no users configured")) }) It("returns error for empty user list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{}, false)) + service := newSubsonicAPIService("test-plugin", router, dataStore, []string{}, false) + ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("no users configured")) @@ -342,24 +349,27 @@ var _ = Describe("SubsonicAPIService", func() { Describe("URL Handling", func() { It("returns error for missing username parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing required parameter")) }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + ctx := GinkgoT().Context() _, err := service.Call(ctx, "://invalid") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("invalid URL")) }) It("extracts endpoint from path correctly", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) + service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user1"}, false) + ctx := GinkgoT().Context() _, err := service.Call(ctx, "/rest/ping.view?u=testuser") Expect(err).ToNot(HaveOccurred()) @@ -370,8 +380,9 @@ var _ = Describe("SubsonicAPIService", func() { Describe("CallRaw", func() { It("returns binary data and content-type", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + ctx := GinkgoT().Context() contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") Expect(err).ToNot(HaveOccurred()) Expect(contentType).To(Equal("image/png")) @@ -379,8 +390,9 @@ var _ = Describe("SubsonicAPIService", func() { }) It("does not set f=json parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") Expect(err).ToNot(HaveOccurred()) @@ -390,32 +402,36 @@ var _ = Describe("SubsonicAPIService", func() { }) It("enforces permission checks", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) + service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false) + ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not authorized")) }) It("returns error when username is missing", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing required parameter")) }) It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) + service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("router not available")) }) It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) + service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true) + ctx := GinkgoT().Context() _, _, err := service.CallRaw(ctx, "://invalid") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("invalid URL")) @@ -424,8 +440,9 @@ var _ = Describe("SubsonicAPIService", func() { Describe("Router Availability", func() { It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) + service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true) + ctx := GinkgoT().Context() _, err := service.Call(ctx, "/ping?u=testuser") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("router not available")) diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index b15cfaf56..9f2ed85f6 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "maps" "os" "path/filepath" "sync" @@ -71,7 +70,7 @@ type taskQueueServiceImpl struct { manager *Manager maxConcurrency int32 db *sql.DB - ctx context.Context //nolint:containedctx // service lifecycle ctx for the worker goroutines + ctx context.Context cancel context.CancelFunc wg sync.WaitGroup mu sync.Mutex @@ -82,9 +81,8 @@ type taskQueueServiceImpl struct { } // newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database. -// 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) +func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) { + dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName) if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("creating plugin data directory: %w", err) } @@ -103,7 +101,7 @@ func newTaskQueueService(ctx context.Context, pluginName string, manager *Manage return nil, fmt.Errorf("creating taskqueue schema: %w", err) } - ctx, cancel := context.WithCancel(ctx) //nolint:gosec // cancel is stored in struct and called in Close() + ctx, cancel := context.WithCancel(manager.ctx) //nolint:gosec // cancel is stored in struct and called in Close() s := &taskQueueServiceImpl{ pluginName: pluginName, @@ -542,7 +540,9 @@ func (s *taskQueueServiceImpl) cleanupLoop() { func (s *taskQueueServiceImpl) runCleanup() { s.mu.Lock() queues := make(map[string]*queueState, len(s.queues)) - maps.Copy(queues, s.queues) + for k, v := range s.queues { + queues[k] = v + } s.mu.Unlock() now := time.Now().UnixMilli() diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index 993ddc7f6..c3ab8d119 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -38,13 +40,17 @@ var _ = Describe("TaskQueueService", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(tmpDir) + conf.Server.DataFolder = 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(ctx, "test_plugin", manager, 5) + service, err = newTaskQueueService("test_plugin", manager, 5) Expect(err).ToNot(HaveOccurred()) }) @@ -361,8 +367,8 @@ var _ = Describe("TaskQueueService", func() { // Enqueue several more tasks — they stay pending since the worker is busy var pendingIDs []string - for i := range 3 { - taskID, err := service.Enqueue(ctx, "clear-test", fmt.Appendf(nil, "task-%d", i)) + for i := 0; i < 3; i++ { + taskID, err := service.Enqueue(ctx, "clear-test", []byte(fmt.Sprintf("task-%d", i))) Expect(err).ToNot(HaveOccurred()) pendingIDs = append(pendingIDs, taskID) } @@ -668,8 +674,8 @@ var _ = Describe("TaskQueueService", func() { Expect(err).ToNot(HaveOccurred()) // Enqueue 5 tasks - for i := range 5 { - _, err := service.Enqueue(ctx, "delay-concurrent", fmt.Appendf(nil, "task-%d", i)) + for i := 0; i < 5; i++ { + _, err := service.Enqueue(ctx, "delay-concurrent", []byte(fmt.Sprintf("task-%d", i))) Expect(err).ToNot(HaveOccurred()) } @@ -689,12 +695,11 @@ var _ = Describe("TaskQueueService", func() { copy(times, dispatchTimes) mu.Unlock() - // Wake-up latency varies per worker, so check offsets from the first dispatch, not gaps. + // Consecutive dispatches should have at least ~160ms gap (80% of 200ms) for i := 1; i < len(times); i++ { - offset := times[i].Sub(times[0]) - minOffset := time.Duration(i)*200*time.Millisecond - 50*time.Millisecond - Expect(offset).To(BeNumerically(">=", minOffset), - fmt.Sprintf("dispatch %d ran %v after the first, expected >= %v", i, offset, minOffset)) + gap := times[i].Sub(times[i-1]) + Expect(gap).To(BeNumerically(">=", 160*time.Millisecond), + fmt.Sprintf("gap between dispatch %d and %d was %v, expected >= 160ms", i-1, i, gap)) } }) }) @@ -725,11 +730,14 @@ 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(ctx, "test_plugin", manager2, 5) + service, err = newTaskQueueService("test_plugin", manager2, 5) Expect(err).ToNot(HaveOccurred()) // Override callback to succeed @@ -767,11 +775,14 @@ 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(ctx, "other_plugin", manager2, 5) + service2, err := newTaskQueueService("other_plugin", manager2, 5) Expect(err).ToNot(HaveOccurred()) defer service2.Close() @@ -842,10 +853,10 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = conf.NewDir(filepath.Join(tmpDir, "cache")) - conf.Server.DataFolder = conf.NewDir(tmpDir) + conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") + conf.Server.DataFolder = tmpDir // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() @@ -1101,7 +1112,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 range 5 { + for i := 0; i < 5; i++ { output, err := callTestTaskQueue(ctx, testTaskQueueInput{ Operation: "enqueue", QueueName: "test-cancel", @@ -1175,11 +1186,11 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Enqueue several tasks - for i := range 4 { + for i := 0; i < 4; i++ { _, err := callTestTaskQueue(ctx, testTaskQueueInput{ Operation: "enqueue", QueueName: "test-clear", - Payload: fmt.Appendf(nil, "task-%d", i), + Payload: []byte(fmt.Sprintf("task-%d", i)), }) Expect(err).ToNot(HaveOccurred()) } diff --git a/plugins/host_users.go b/plugins/host_users.go index 28f4dc9b0..a56c8f866 100644 --- a/plugins/host_users.go +++ b/plugins/host_users.go @@ -23,7 +23,7 @@ func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) h } func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) { - users, err := s.ds.User().GetAll(ctx) + users, err := s.ds.User(ctx).GetAll() if err != nil { return nil, err } diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 56edd9f67..1c0de7d03 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -35,21 +37,21 @@ var _ = Describe("UsersService", Ordered, func() { var mockUserRepo *tests.MockedUserRepo BeforeEach(func() { - mockUserRepo = ds.User().(*tests.MockedUserRepo) + mockUserRepo = ds.User(ctx).(*tests.MockedUserRepo) // Add test users - _ = mockUserRepo.Put(ctx, &model.User{ + _ = mockUserRepo.Put(&model.User{ ID: "user1", UserName: "alice", Name: "Alice Admin", IsAdmin: true, }) - _ = mockUserRepo.Put(ctx, &model.User{ + _ = mockUserRepo.Put(&model.User{ ID: "user2", UserName: "bob", Name: "Bob User", IsAdmin: false, }) - _ = mockUserRepo.Put(ctx, &model.User{ + _ = mockUserRepo.Put(&model.User{ ID: "user3", UserName: "charlie", Name: "Charlie User", @@ -144,21 +146,21 @@ var _ = Describe("UsersService", Ordered, func() { var mockUserRepo *tests.MockedUserRepo BeforeEach(func() { - mockUserRepo = ds.User().(*tests.MockedUserRepo) + mockUserRepo = ds.User(ctx).(*tests.MockedUserRepo) // Add test users - alice is admin, bob and charlie are not - _ = mockUserRepo.Put(ctx, &model.User{ + _ = mockUserRepo.Put(&model.User{ ID: "user1", UserName: "alice", Name: "Alice Admin", IsAdmin: true, }) - _ = mockUserRepo.Put(ctx, &model.User{ + _ = mockUserRepo.Put(&model.User{ ID: "user2", UserName: "bob", Name: "Bob User", IsAdmin: false, }) - _ = mockUserRepo.Put(ctx, &model.User{ + _ = mockUserRepo.Put(&model.User{ ID: "user3", UserName: "charlie", Name: "Charlie User", @@ -458,20 +460,20 @@ func setupTestUsersPlugin() (*testUsersSetup, error) { } // createTestUsers creates standard test users in the mock repo -func createTestUsers(ctx context.Context, mockUserRepo *tests.MockedUserRepo) { - _ = mockUserRepo.Put(ctx, &model.User{ +func createTestUsers(mockUserRepo *tests.MockedUserRepo) { + _ = mockUserRepo.Put(&model.User{ ID: "user1", UserName: "alice", Name: "Alice Admin", IsAdmin: true, }) - _ = mockUserRepo.Put(ctx, &model.User{ + _ = mockUserRepo.Put(&model.User{ ID: "user2", UserName: "bob", Name: "Bob User", IsAdmin: false, }) - _ = mockUserRepo.Put(ctx, &model.User{ + _ = mockUserRepo.Put(&model.User{ ID: "user3", UserName: "charlie", Name: "Charlie User", @@ -482,7 +484,7 @@ func createTestUsers(ctx context.Context, mockUserRepo *tests.MockedUserRepo) { // setupTestUsersConfig sets up common plugin configuration func setupTestUsersConfig(tmpDir string) { conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false } @@ -560,7 +562,7 @@ func setupUsersIntegrationManagerWithEnabled(enabled, allUsers bool, allowedUser }}) mockUserRepo := tests.CreateMockUserRepo() - createTestUsers(GinkgoT().Context(), mockUserRepo) + createTestUsers(mockUserRepo) dataStore := &tests.MockDataStore{ MockedPlugin: mockPluginRepo, diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index a58deb129..74238a422 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -5,12 +5,10 @@ import ( "errors" "fmt" "maps" - "net" "net/http" "net/url" "strings" "sync" - "syscall" "time" "github.com/gorilla/websocket" @@ -56,7 +54,6 @@ type wsConnection struct { // webSocketServiceImpl implements host.WebSocketService. // It provides plugins with WebSocket communication capabilities. type webSocketServiceImpl struct { - baseCtx context.Context //nolint:containedctx // bounds the read loops, which outlive the Connect() call pluginName string manager *Manager requiredHosts []string @@ -66,9 +63,8 @@ type webSocketServiceImpl struct { } // newWebSocketService creates a new WebSocketService for a plugin. -func newWebSocketService(ctx context.Context, pluginName string, manager *Manager, permission *WebSocketPermission) *webSocketServiceImpl { +func newWebSocketService(pluginName string, manager *Manager, permission *WebSocketPermission) *webSocketServiceImpl { return &webSocketServiceImpl{ - baseCtx: ctx, pluginName: pluginName, manager: manager, requiredHosts: permission.RequiredHosts, @@ -114,7 +110,6 @@ func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, heade // Establish WebSocket connection dialer := websocket.Dialer{ HandshakeTimeout: 30 * time.Second, - NetDialContext: (&net.Dialer{Control: s.dialControl, Resolver: dialResolver}).DialContext, } conn, resp, err := dialer.DialContext(ctx, urlStr, httpHeaders) @@ -134,12 +129,11 @@ func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, heade s.connections[connectionID] = wsConn s.mu.Unlock() - // 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) + // 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) log.Debug(ctx, "WebSocket connected", "plugin", s.pluginName, "connectionID", connectionID, "url", urlStr) return connectionID, nil @@ -246,11 +240,18 @@ func (s *webSocketServiceImpl) getConnection(connectionID string) (*wsConnection } func (s *webSocketServiceImpl) isHostAllowed(host string) bool { - return isHostInAllowlist(s.requiredHosts, extractHostname(host)) -} + // Strip port from host if present + hostWithoutPort := host + if idx := strings.LastIndex(host, ":"); idx != -1 { + hostWithoutPort = host[:idx] + } -func (s *webSocketServiceImpl) dialControl(_, address string, _ syscall.RawConn) error { - return checkPrivateDial(s.requiredHosts, address) + for _, pattern := range s.requiredHosts { + if matchHostPattern(pattern, hostWithoutPort) { + return true + } + } + return false } // matchHostPattern matches a host against a pattern. @@ -301,7 +302,8 @@ func (s *webSocketServiceImpl) readLoop(ctx context.Context, connectionID string if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { closeCode := websocket.CloseNoStatusReceived closeReason := "" - if ce, ok := errors.AsType[*websocket.CloseError](err); ok { + var ce *websocket.CloseError + if errors.As(err, &ce) { closeCode = ce.Code closeReason = ce.Text } diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index 9b94e4b70..83fca9898 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -8,7 +10,6 @@ import ( "maps" "net/http" "net/http/httptest" - "net/url" "os" "path/filepath" "strings" @@ -50,7 +51,7 @@ var _ = Describe("WebSocketService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin @@ -143,18 +144,20 @@ var _ = Describe("WebSocketService", Ordered, func() { Expect(allowed).To(BeFalse()) }) - DescribeTable("should match against the host with its port stripped", - func(allowed []string, host string, expected bool) { - svc := &webSocketServiceImpl{requiredHosts: allowed} - Expect(svc.isHostAllowed(host)).To(Equal(expected)) - }, - Entry("hostname with port", []string{"example.com"}, "example.com:8080", true), - Entry("IPv6 with port", []string{"::1"}, "[::1]:8080", true), - Entry("IPv6 without port", []string{"::1"}, "[::1]", true), - Entry("host not in the list", []string{"::2"}, "[::1]:8080", false), - // "localhost:*" is matched against the stripped "localhost", so it never hits - Entry("port wildcards are not supported", []string{"localhost:*"}, "localhost:8080", false), - ) + It("should strip port before checking host", func() { + // Implementation strips port before matching against patterns + // test-websocket manifest has "localhost:*" which matches "localhost" + // after port stripping + // Note: The port wildcard pattern isn't actually implemented, but + // since port is stripped, "localhost:*" is compared against "localhost" + // which won't match. To make localhost work, we'd need exact "localhost" + // in the allowed hosts list. + + // Testing that port is properly stripped + // The pattern "localhost:*" won't match "localhost" due to exact match + allowed := testService.isHostAllowed("localhost:8080") + Expect(allowed).To(BeFalse()) + }) }) Describe("Connection Management", func() { @@ -500,51 +503,6 @@ var _ = Describe("WebSocketService", Ordered, func() { }) }) - Describe("Private address protection", func() { - var wsServer *httptest.Server - var savedHosts []string - - BeforeEach(func() { - stubLocalhostDNS() - savedHosts = testService.requiredHosts - upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} - wsServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if conn, err := upgrader.Upgrade(w, r, nil); err == nil { - _, _, _ = conn.ReadMessage() - } - })) - }) - - AfterEach(func() { - testService.closeAllConnections() - testService.requiredHosts = savedHosts - wsServer.Close() - }) - - serverPort := func() string { - u, _ := url.Parse(wsServer.URL) - return u.Port() - } - - It("blocks an allowlisted hostname that resolves to loopback", func() { - testService.requiredHosts = []string{"localhost."} - _, err := testService.Connect(GinkgoT().Context(), "ws://localhost.:"+serverPort(), nil, "") - Expect(err).To(MatchError(ContainSubstring("private/loopback"))) - }) - - It("allows loopback when a CIDR entry covers it", func() { - testService.requiredHosts = []string{"127.0.0.0/8"} - _, err := testService.Connect(GinkgoT().Context(), "ws://127.0.0.1:"+serverPort(), nil, "") - Expect(err).ToNot(HaveOccurred()) - }) - - It("allows loopback when the allowlist is the bare '*' wildcard", func() { - testService.requiredHosts = []string{"*"} - _, err := testService.Connect(GinkgoT().Context(), "ws://localhost.:"+serverPort(), nil, "") - Expect(err).ToNot(HaveOccurred()) - }) - }) - Describe("Plugin Unload", func() { It("should close all connections when plugin is unloaded", func() { // Create a fresh server for this test diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 9e02115e7..c66b027d7 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -14,10 +14,6 @@ const ( FuncLyricsGetLyrics = "nd_lyrics_get_lyrics" ) -// maxConcurrentLyricsCalls caps in-flight lyrics calls per plugin: clients prefetch -// lyrics for whole queues, and the resulting burst can rate-limit upstream providers. -const maxConcurrentLyricsCalls = 2 - func init() { registerCapability( CapabilityLyrics, @@ -35,15 +31,9 @@ type LyricsPlugin struct { plugin *plugin } -// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response -// via model.ParseLyrics (TTML/SRT/YAML/LRC/plain). +// GetLyrics calls the plugin to fetch lyrics, then parses the raw text responses +// using model.ToLyrics. func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { - select { - case l.plugin.lyricsSem <- struct{}{}: - defer func() { <-l.plugin.lyricsSem }() - case <-ctx.Done(): - return nil, ctx.Err() - } req := capabilities.GetLyricsRequest{ Track: mediaFileToTrackInfo(l.plugin, mf), } @@ -54,25 +44,19 @@ 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.ParseLyrics(ctx, "", lang, []byte(lt.Text)) + parsed, err := model.ToLyrics(lang, lt.Text) if err != nil { - log.Warn(ctx, "Error parsing plugin lyrics", err) + log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err) continue } - for _, lyric := range parsed { - if !lyric.IsEmpty() { - result = append(result, lyric) - } + if parsed != nil && !parsed.IsEmpty() { + result = append(result, *parsed) } } return result, nil diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index fe15e40b2..a1a6c1809 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -1,8 +1,8 @@ +//go:build !windows + package plugins import ( - "context" - "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -71,45 +71,6 @@ var _ = Describe("LyricsPlugin", Ordered, func() { Expect(result[0].Lang).To(Equal("xxx")) }) - It("blocks new calls while the per-plugin concurrency cap is saturated", func() { - sem := provider.plugin.lyricsSem - for range cap(sem) { - sem <- struct{}{} - } - - ctx := GinkgoT().Context() - track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} - done := make(chan error, 1) - go func() { - _, err := provider.GetLyrics(ctx, track) - done <- err - }() - - Consistently(done, "500ms").ShouldNot(Receive()) - <-sem // free one slot; the pending call should now proceed - Eventually(done).Should(Receive(BeNil())) - for range cap(sem) - 1 { - <-sem - } - }) - - It("gives up waiting for a slot when the context is cancelled", func() { - sem := provider.plugin.lyricsSem - for range cap(sem) { - sem <- struct{}{} - } - defer func() { - for range cap(sem) { - <-sem - } - }() - - ctx, cancel := context.WithCancel(GinkgoT().Context()) - cancel() - _, err := provider.GetLyrics(ctx, &model.MediaFile{ID: "track-1"}) - Expect(err).To(MatchError(context.Canceled)) - }) - It("returns error when plugin returns error", func() { manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ "test-lyrics": {"error": "service unavailable"}, @@ -122,32 +83,6 @@ 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 90247a75c..0e9419bfd 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "os" "path/filepath" "runtime" "sync" @@ -49,7 +50,7 @@ type PluginMetricsRecorder interface { type Manager struct { mu sync.RWMutex plugins map[string]*plugin - ctx context.Context //nolint:containedctx // manager lifecycle ctx, cancelled by Stop + ctx context.Context cancel context.CancelFunc cache wazero.CompilationCache stopped atomic.Bool // Set to true when Stop() is called @@ -61,9 +62,6 @@ type Manager struct { debounceTimers map[string]*time.Timer debounceMu sync.Mutex - // transient is set by LoadPlugins, and nil for a server Start. - transient *transientLoad - // SubsonicAPI host function dependencies (set once before Start, not modified after) subsonicRouter SubsonicRouter ds model.DataStore @@ -113,28 +111,46 @@ func (m *Manager) Start(ctx context.Context) error { } if m.subsonicRouter == nil { - log.Fatal(ctx, "Plugin manager requires the SubsonicAPI router to be configured") + log.Fatal(ctx, "Plugin manager requires DataStore to be configured") } - cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") + // Set extism log level based on plugin-specific config or global log level + pluginLogLevel := conf.Server.Plugins.LogLevel + if pluginLogLevel == "" { + pluginLogLevel = conf.Server.LogLevel + } + extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel))) + + m.ctx, m.cancel = context.WithCancel(ctx) + + // Initialize wazero compilation cache for better performance + cacheDir := filepath.Join(conf.Server.CacheFolder, "plugins") purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize) - if err := m.initRuntime(ctx, cacheDir); err != nil { - return err + var err error + m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir) + if err != nil { + log.Error(ctx, "Failed to create wazero compilation cache", err) + return fmt.Errorf("creating wazero compilation cache: %w", err) } - if conf.Server.Plugins.Folder.String() == "" { + folder := conf.Server.Plugins.Folder + if folder == "" { log.Debug(ctx, "No plugins folder configured") return nil } - folder := conf.Server.Plugins.Folder.MustPath() + // 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) + } log.Info(ctx, "Starting plugin manager", "folder", folder) // Clear previous error states so plugins can be retried on restart adminCtx := adminContext(ctx) - if err := m.ds.Plugin().ClearErrors(adminCtx); err != nil { + if err := m.ds.Plugin(adminCtx).ClearErrors(); err != nil { log.Error(ctx, "Error clearing plugin errors", err) } @@ -161,49 +177,6 @@ func (m *Manager) Start(ctx context.Context) error { return nil } -// initRuntime prepares the extism/wazero runtime that instantiating a plugin needs. -func (m *Manager) initRuntime(ctx context.Context, cacheDir string) error { - pluginLogLevel := conf.Server.Plugins.LogLevel - if pluginLogLevel == "" { - pluginLogLevel = conf.Server.LogLevel - } - extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel))) - - m.ctx, m.cancel = context.WithCancel(ctx) - - var err error - m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir) - if err != nil { - log.Error(ctx, "Failed to create wazero compilation cache", err) - return fmt.Errorf("creating wazero compilation cache: %w", err) - } - return nil -} - -// transientLoad scopes a load that will not outlive the command asking for it; see LoadPlugins. -type transientLoad struct { - only []string - runInit bool -} - -// LoadPlugins loads the plugins named in only, so a CLI sees the agents a server would. Each is -// instantiated, creating any KVStore, TaskQueue or Storage it declares; call Stop when done. -func (m *Manager) LoadPlugins(ctx context.Context, only []string, runInit bool) error { - if !conf.Server.Plugins.Enabled || conf.Server.Plugins.Folder.String() == "" || len(only) == 0 { - return nil - } - m.transient = &transientLoad{only: only, runInit: runInit} - - cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") - if err := m.initRuntime(ctx, cacheDir); err != nil { - return err - } - if err := m.loadEnabledPlugins(ctx); err != nil { - return fmt.Errorf("loading enabled plugins: %w", err) - } - return nil -} - // Stop shuts down the plugin manager and releases all resources. func (m *Manager) Stop() error { // Mark as stopped first to prevent new operations @@ -274,7 +247,7 @@ func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin) } -func (m *Manager) LoadLyricsProvider(name string) (lyrics.Provider, bool) { +func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin) } @@ -323,9 +296,9 @@ func (m *Manager) EnablePlugin(ctx context.Context, id string) error { } adminCtx := adminContext(ctx) - repo := m.ds.Plugin() + repo := m.ds.Plugin(adminCtx) - plugin, err := repo.Get(adminCtx, id) + plugin, err := repo.Get(id) if err != nil { return fmt.Errorf("getting plugin from DB: %w", err) } @@ -344,7 +317,7 @@ func (m *Manager) EnablePlugin(ctx context.Context, id string) error { // Store error and return plugin.LastError = err.Error() plugin.UpdatedAt = time.Now() - _ = repo.Put(adminCtx, plugin) + _ = repo.Put(plugin) return fmt.Errorf("loading plugin: %w", err) } @@ -352,7 +325,7 @@ func (m *Manager) EnablePlugin(ctx context.Context, id string) error { plugin.Enabled = true plugin.LastError = "" plugin.UpdatedAt = time.Now() - if err := repo.Put(adminCtx, plugin); err != nil { + if err := repo.Put(plugin); err != nil { // Unload since we couldn't update DB _ = m.unloadPlugin(id) return fmt.Errorf("updating plugin in DB: %w", err) @@ -371,9 +344,9 @@ func (m *Manager) DisablePlugin(ctx context.Context, id string) error { } adminCtx := adminContext(ctx) - repo := m.ds.Plugin() + repo := m.ds.Plugin(adminCtx) - plugin, err := repo.Get(adminCtx, id) + plugin, err := repo.Get(id) if err != nil { return fmt.Errorf("getting plugin from DB: %w", err) } @@ -390,7 +363,7 @@ func (m *Manager) DisablePlugin(ctx context.Context, id string) error { // Update DB plugin.Enabled = false plugin.UpdatedAt = time.Now() - if err := repo.Put(adminCtx, plugin); err != nil { + if err := repo.Put(plugin); err != nil { return fmt.Errorf("updating plugin in DB: %w", err) } @@ -408,14 +381,14 @@ func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON strin } adminCtx := adminContext(ctx) - repo := m.ds.Plugin() + repo := m.ds.Plugin(adminCtx) - plugin, err := repo.Get(adminCtx, id) + plugin, err := repo.Get(id) if err != nil { 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) } @@ -458,7 +431,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.String() + folder := conf.Server.Plugins.Folder if folder == "" { return fmt.Errorf("plugins folder not configured") } @@ -476,9 +449,9 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn } adminCtx := adminContext(ctx) - repo := m.ds.Plugin() + repo := m.ds.Plugin(adminCtx) - plugin, err := repo.Get(adminCtx, id) + plugin, err := repo.Get(id) if err != nil { return fmt.Errorf("getting plugin from DB: %w", err) } @@ -493,7 +466,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 @@ -512,7 +485,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn log.Debug(ctx, "Plugin was not loaded", "plugin", id) } plugin.Enabled = false - if err := repo.Put(adminCtx, plugin); err != nil { + if err := repo.Put(plugin); err != nil { return fmt.Errorf("updating plugin in DB: %w", err) } log.Info(ctx, "Disabled plugin due to "+disableReason, "plugin", id) @@ -520,7 +493,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn return nil } - if err := repo.Put(adminCtx, plugin); err != nil { + if err := repo.Put(plugin); err != nil { return fmt.Errorf("updating plugin in DB: %w", err) } @@ -532,7 +505,7 @@ func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn if err := m.loadPluginWithConfig(plugin); err != nil { plugin.LastError = err.Error() plugin.Enabled = false - _ = repo.Put(adminCtx, plugin) + _ = repo.Put(plugin) return fmt.Errorf("reloading plugin: %w", err) } } @@ -586,10 +559,10 @@ func (m *Manager) UnloadDisabledPlugins(ctx context.Context) { } adminCtx := adminContext(ctx) - repo := m.ds.Plugin() + repo := m.ds.Plugin(adminCtx) // Get all disabled plugins from the database - plugins, err := repo.GetAll(adminCtx, model.QueryOptions{ + plugins, err := repo.GetAll(model.QueryOptions{ Filters: squirrel.Eq{"enabled": false}, }) if err != nil { @@ -624,7 +597,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 3dbfa45ee..f985fcd84 100644 --- a/plugins/manager_cache_test.go +++ b/plugins/manager_cache_test.go @@ -4,7 +4,6 @@ import ( "context" "os" "path/filepath" - "strconv" "time" "github.com/dustin/go-humanize" @@ -144,7 +143,7 @@ var _ = Describe("purgeCacheBySize", func() { // Create 5 files, 1MiB each (total 5MiB) for i := range 5 { - path := filepath.Join(cacheDir, filepath.Join("dir", "file"+strconv.Itoa(i)+".bin")) + path := filepath.Join(cacheDir, filepath.Join("dir", "file"+string(rune('0'+i))+".bin")) createFileWithSize(path, 1*1024*1024, now.Add(-time.Duration(5-i)*time.Hour)) } diff --git a/plugins/manager_call_test.go b/plugins/manager_call_test.go index 5e2c6385e..3e64f1cee 100644 --- a/plugins/manager_call_test.go +++ b/plugins/manager_call_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index cca87e5b0..ccda9e4cb 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "io" - "slices" "time" extism "github.com/extism/go-sdk" @@ -14,6 +13,8 @@ import ( "github.com/navidrome/navidrome/plugins/host" "github.com/navidrome/navidrome/scheduler" "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" "golang.org/x/sync/errgroup" ) @@ -29,23 +30,11 @@ 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, error) + create func(*serviceContext) ([]extism.HostFunction, io.Closer) } // hostServices defines all available host services. @@ -54,136 +43,106 @@ 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, error) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { service := newConfigService(ctx.pluginName, ctx.config) - return host.RegisterConfigHostFunctions(service), nil, nil + return host.RegisterConfigHostFunctions(service), nil }, }, { name: "SubsonicAPI", hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != 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 + 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 }, }, { name: "Scheduler", hasPermission: func(p *Permissions) bool { return p != nil && p.Scheduler != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { service := newSchedulerService(ctx.pluginName, ctx.manager, scheduler.GetInstance()) - return host.RegisterSchedulerHostFunctions(service), service, nil + return host.RegisterSchedulerHostFunctions(service), service }, }, { name: "WebSocket", hasPermission: func(p *Permissions) bool { return p != nil && p.Websocket != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { perm := ctx.permissions.Websocket - service := newWebSocketService(ctx.baseCtx(), ctx.pluginName, ctx.manager, perm) - return host.RegisterWebSocketHostFunctions(service), service, nil + service := newWebSocketService(ctx.pluginName, ctx.manager, perm) + return host.RegisterWebSocketHostFunctions(service), service }, }, { name: "Artwork", hasPermission: func(p *Permissions) bool { return p != nil && p.Artwork != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { service := newArtworkService() - return host.RegisterArtworkHostFunctions(service), nil, nil + return host.RegisterArtworkHostFunctions(service), nil }, }, { name: "Cache", hasPermission: func(p *Permissions) bool { return p != nil && p.Cache != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { service := newCacheService(ctx.pluginName) - return host.RegisterCacheHostFunctions(service), service, nil + return host.RegisterCacheHostFunctions(service), service }, }, { name: "Library", hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { perm := ctx.permissions.Library service := newLibraryService(ctx.manager.ds, perm, ctx.allowedLibraries, ctx.allLibraries) - return host.RegisterLibraryHostFunctions(service), nil, nil + return host.RegisterLibraryHostFunctions(service), nil }, }, { name: "KVStore", hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { perm := ctx.permissions.Kvstore - service, err := newKVStoreService(ctx.baseCtx(), ctx.pluginName, perm) + service, err := newKVStoreService(ctx.manager.ctx, ctx.pluginName, perm) if err != nil { - return nil, nil, err + log.Error("Failed to create KVStore service", "plugin", ctx.pluginName, err) + return nil, nil } - return host.RegisterKVStoreHostFunctions(service), service, nil + return host.RegisterKVStoreHostFunctions(service), service }, }, { name: "Users", hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers) - 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 + return host.RegisterUsersHostFunctions(service), nil }, }, { name: "HTTP", hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { perm := ctx.permissions.Http service := newHTTPService(ctx.pluginName, perm) - return host.RegisterHTTPHostFunctions(service), service, nil + return host.RegisterHTTPHostFunctions(service), nil }, }, { name: "Task", hasPermission: func(p *Permissions) bool { return p != nil && p.Taskqueue != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { perm := ctx.permissions.Taskqueue maxConcurrency := int32(1) if perm.MaxConcurrency > 0 { maxConcurrency = int32(perm.MaxConcurrency) } - service, err := newTaskQueueService(ctx.baseCtx(), ctx.pluginName, ctx.manager, maxConcurrency) + service, err := newTaskQueueService(ctx.pluginName, ctx.manager, maxConcurrency) if err != nil { - return nil, nil, err + log.Error("Failed to create Task service", "plugin", ctx.pluginName, err) + return nil, nil } - return host.RegisterTaskHostFunctions(service), service, nil - }, - }, - { - name: "Storage", - hasPermission: func(p *Permissions) bool { return p != nil && p.Storage != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - service, err := newStorageService(ctx.pluginName) - if err != nil { - return nil, nil, err - } - return host.RegisterStorageHostFunctions(service), nil, nil - }, - }, - { - name: "ScrobbleRetriever", - hasPermission: func(p *Permissions) bool { return p != nil && p.ScrobbleRetriever != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - service := newScrobbleRetrieverService(ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) - return host.RegisterScrobbleRetrieverHostFunctions(service), nil, nil + return host.RegisterTaskHostFunctions(service), service }, }, } @@ -196,12 +155,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) } @@ -219,9 +178,9 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { } adminCtx := adminContext(ctx) - repo := m.ds.Plugin() + repo := m.ds.Plugin(adminCtx) - plugins, err := repo.GetAll(adminCtx) + plugins, err := repo.GetAll() if err != nil { return fmt.Errorf("reading plugins from DB: %w", err) } @@ -233,11 +192,6 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { if !p.Enabled { continue } - // Instantiating a plugin creates its host services, so a transient load takes only the - // ones it may actually consult. - if m.transient != nil && !slices.Contains(m.transient.only, p.ID) { - continue - } plugin := p // Capture for goroutine g.Go(func() error { @@ -252,24 +206,22 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error { }() if err := m.loadPluginWithConfig(&plugin); err != nil { - // A transient load must not disable the user's plugin just for looking at it. - if m.transient == nil { - plugin.LastError = err.Error() - plugin.Enabled = false - plugin.UpdatedAt = time.Now() - if putErr := repo.Put(adminCtx, &plugin); putErr != nil { - log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr) - } + // Store error in DB + plugin.LastError = err.Error() + plugin.Enabled = false + plugin.UpdatedAt = time.Now() + if putErr := repo.Put(&plugin); putErr != nil { + log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr) } log.Error(ctx, "Failed to load plugin", "plugin", plugin.ID, err) return nil } // Clear any previous error - if plugin.LastError != "" && m.transient == nil { + if plugin.LastError != "" { plugin.LastError = "" plugin.UpdatedAt = time.Now() - if putErr := repo.Put(adminCtx, &plugin); putErr != nil { + if putErr := repo.Put(&plugin); putErr != nil { log.Error(ctx, "Failed to clear plugin error in DB", "plugin", plugin.ID, putErr) } } @@ -291,17 +243,12 @@ 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() { return fmt.Errorf("manager is stopped") } - if !validPluginID(p.ID) { - return fmt.Errorf("invalid plugin ID %q", p.ID) - } - // Track this operation m.loadWg.Add(1) defer m.loadWg.Done() @@ -338,47 +285,36 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("opening package: %w", err) } - pluginManifest := buildExtismManifest(pkg, pluginConfig) + // Build extism manifest + pluginManifest := extism.Manifest{ + Wasm: []extism.Wasm{ + extism.WasmData{Data: pkg.WasmBytes, Name: "main"}, + }, + Config: pluginConfig, + Timeout: uint64(defaultTimeout.Milliseconds()), + } - // Configure filesystem access for library permission, applied per instance - var fsConfig wazero.FSConfig - if pkg.Manifest.HasLibraryFilesystemPermission() || pkg.Manifest.HasStoragePermission() { - mounts := []mount{} + if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil { + if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 { + pluginManifest.AllowedHosts = hosts + } + } - if pkg.Manifest.HasLibraryFilesystemPermission() { - adminCtx := adminContext(ctx) - libraries, err := m.ds.Library().GetAll(adminCtx) - if err != nil { - return fmt.Errorf("failed to get libraries for filesystem access: %w", err) - } - mounts = buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess) + // Configure filesystem access for library permission + if pkg.Manifest.HasLibraryFilesystemPermission() { + adminCtx := adminContext(ctx) + libraries, err := m.ds.Library(adminCtx).GetAll() + if err != nil { + return fmt.Errorf("failed to get libraries for filesystem access: %w", err) } - if pkg.Manifest.HasStoragePermission() { - pluginStore := getHostStoragePath(p.ID) - log.Info(ctx, "Granting read-write filesystem access to plugin storage", "path", pluginStore, "id", p.ID) - - mounts = append(mounts, mount{ - hostPath: pluginStore, - guestPath: storageMount, - }) - } - - fsConfig = buildFSConfig(mounts) + allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess) + pluginManifest.AllowedPaths = allowedPaths } // 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, @@ -392,10 +328,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { } for _, entry := range hostServices { if entry.hasPermission(pkg.Manifest.Permissions) { - funcs, closer, err := entry.create(svcCtx) - if err != nil { - return fmt.Errorf("creating %s service: %w", entry.name, err) - } + funcs, closer := entry.create(svcCtx) hostFunctions = append(hostFunctions, funcs...) if closer != nil { closers = append(closers, closer) @@ -408,6 +341,12 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { WithCompilationCache(m.cache). WithCloseOnContextDone(true) + // Enable experimental threads if requested in manifest + if pkg.Manifest.HasExperimentalThreads() { + runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads) + log.Debug(ctx, "Enabling experimental threads support") + } + extismConfig := extism.PluginConfig{ EnableWasi: true, RuntimeConfig: runtimeConfig, @@ -419,7 +358,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { } // Create instance to detect capabilities - instance, err := compiled.Instance(ctx, instanceConfig(fsConfig)) + instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{}) if err != nil { compiled.Close(ctx) return fmt.Errorf("creating instance: %w", err) @@ -434,7 +373,8 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("manifest validation: %w", err) } - loadedPlugin := &plugin{ + m.mu.Lock() + m.plugins[p.ID] = &plugin{ name: p.ID, path: p.Path, manifest: pkg.Manifest, @@ -445,32 +385,15 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { allowedUserIDs: allowedUsers, allUsers: p.AllUsers, libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), - fsConfig: fsConfig, - lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls), } - m.mu.Lock() - m.plugins[p.ID] = loadedPlugin m.mu.Unlock() - loaded = true - // Init is the plugin's first chance to run arbitrary code: open sockets, create task queues, - // schedule work. Only a caller that already intends to reach the network asks for it. - // Use the local: loads run concurrently, so reading the map back here would race the writes. - if m.transient == nil || m.transient.runInit { - callPluginInit(ctx, loadedPlugin) - } + // Call plugin init function + callPluginInit(ctx, m.plugins[p.ID]) 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) { @@ -498,12 +421,31 @@ func parsePluginConfig(configJSON string) (map[string]string, error) { return pluginConfig, nil } -// buildExtismManifest describes the plugin to extism. It must never set AllowedPaths (extism would replace our -// jailed FSConfig) nor AllowedHosts (extism's http_request has no SSRF guard; plugins must use host.HTTPSend). -func buildExtismManifest(pkg *ndpPackage, pluginConfig map[string]string) extism.Manifest { - return extism.Manifest{ - Wasm: []extism.Wasm{extism.WasmData{Data: pkg.WasmBytes, Name: "main"}}, - Config: pluginConfig, - Timeout: uint64(defaultTimeout.Milliseconds()), +// buildAllowedPaths constructs the extism AllowedPaths map for filesystem access. +// When allowWriteAccess is false (default), paths are prefixed with "ro:" for read-only. +// Only libraries that match the allowed set (or all libraries if allLibraries is true) are included. +func buildAllowedPaths(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) map[string]string { + allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs)) + for _, id := range allowedLibraryIDs { + allowedLibrarySet[id] = struct{}{} } + allowedPaths := make(map[string]string) + for _, lib := range libraries { + _, allowed := allowedLibrarySet[lib.ID] + if allLibraries || allowed { + mountPoint := toPluginMountPoint(int32(lib.ID)) + hostPath := lib.Path + if !allowWriteAccess { + hostPath = "ro:" + hostPath + } + allowedPaths[hostPath] = mountPoint + log.Trace(ctx, "Added library to allowed paths", "libraryID", lib.ID, "mountPoint", mountPoint, "writeAccess", allowWriteAccess, "hostPath", hostPath) + } + } + if allowWriteAccess { + log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries) + } else { + log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries) + } + return allowedPaths } diff --git a/plugins/manager_loader_load_test.go b/plugins/manager_loader_load_test.go deleted file mode 100644 index eb004276e..000000000 --- a/plugins/manager_loader_load_test.go +++ /dev/null @@ -1,76 +0,0 @@ -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_loader_test.go b/plugins/manager_loader_test.go index 85a636202..cc07f0611 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -6,40 +6,6 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("buildExtismManifest", func() { - var pkg *ndpPackage - - BeforeEach(func() { - pkg = &ndpPackage{ - WasmBytes: []byte("wasm"), - Manifest: &Manifest{Permissions: &Permissions{ - Library: &LibraryPermission{Reason: new("test"), Filesystem: true}, - Http: &HTTPPermission{Reason: new("test"), RequiredHosts: []string{"example.com"}}, - }}, - } - }) - - It("never sets AllowedPaths, even with filesystem permission", func() { - Expect(buildExtismManifest(pkg, nil).AllowedPaths).To(BeEmpty()) - }) - - It("never sets AllowedHosts, so plugin HTTP can't bypass the host service's SSRF guard", func() { - Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(BeEmpty()) - }) -}) - -var _ = Describe("loadPluginWithConfig", func() { - // Discovery already rejects these, but a row predating that check, or one - // left behind by a failed sync, must not reach the mount setup - It("refuses a plugin whose ID is not usable as a directory name", func() { - m := &Manager{plugins: make(map[string]*plugin)} - - err := m.loadPluginWithConfig(&model.Plugin{ID: "..", Path: "/does/not/matter.ndp"}) - - Expect(err).To(MatchError(ContainSubstring("invalid plugin ID"))) - }) -}) - var _ = Describe("parsePluginConfig", func() { It("returns nil for empty string", func() { result, err := parsePluginConfig("") @@ -91,3 +57,66 @@ var _ = Describe("parsePluginConfig", func() { Expect(result).ToNot(BeNil()) }) }) + +var _ = Describe("buildAllowedPaths", func() { + var libraries model.Libraries + + BeforeEach(func() { + libraries = model.Libraries{ + {ID: 1, Path: "/music/library1"}, + {ID: 2, Path: "/music/library2"}, + {ID: 3, Path: "/music/library3"}, + } + }) + + Context("read-only (default)", func() { + It("mounts all libraries with ro: prefix when allLibraries is true", func() { + result := buildAllowedPaths(nil, libraries, nil, true, false) + Expect(result).To(HaveLen(3)) + Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1")) + Expect(result).To(HaveKeyWithValue("ro:/music/library2", "/libraries/2")) + Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3")) + }) + + It("mounts only selected libraries with ro: prefix", func() { + result := buildAllowedPaths(nil, libraries, []int{1, 3}, false, false) + Expect(result).To(HaveLen(2)) + Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1")) + Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3")) + Expect(result).ToNot(HaveKey("ro:/music/library2")) + }) + }) + + Context("read-write (allowWriteAccess=true)", func() { + It("mounts all libraries without ro: prefix when allLibraries is true", func() { + result := buildAllowedPaths(nil, libraries, nil, true, true) + Expect(result).To(HaveLen(3)) + Expect(result).To(HaveKeyWithValue("/music/library1", "/libraries/1")) + Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2")) + Expect(result).To(HaveKeyWithValue("/music/library3", "/libraries/3")) + }) + + It("mounts only selected libraries without ro: prefix", func() { + result := buildAllowedPaths(nil, libraries, []int{2}, false, true) + Expect(result).To(HaveLen(1)) + Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2")) + }) + }) + + Context("edge cases", func() { + It("returns empty map when no libraries match", func() { + result := buildAllowedPaths(nil, libraries, []int{99}, false, false) + Expect(result).To(BeEmpty()) + }) + + It("returns empty map when libraries list is empty", func() { + result := buildAllowedPaths(nil, nil, []int{1}, false, false) + Expect(result).To(BeEmpty()) + }) + + It("returns empty map when allLibraries is false and no IDs provided", func() { + result := buildAllowedPaths(nil, libraries, nil, false, false) + Expect(result).To(BeEmpty()) + }) + }) +}) diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 015cd7f4c..1d4a8c301 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -4,11 +4,9 @@ import ( "context" "crypto/rand" "errors" - "fmt" "io" extism "github.com/extism/go-sdk" - "github.com/navidrome/navidrome/model" "github.com/tetratelabs/wazero" ) @@ -24,24 +22,15 @@ type plugin struct { allowedUserIDs []string // User IDs this plugin can access (from DB configuration) allUsers bool // If true, plugin can access all users libraries libraryAccess - lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics) - fsConfig wazero.FSConfig // Sandboxed library mounts, nil if no filesystem permission -} - -// instanceConfig is used by every call site, so all instances get the sandboxed mounts. -func instanceConfig(fsConfig wazero.FSConfig) extism.PluginInstanceConfig { - moduleConfig := wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader) - if fsConfig != nil { - moduleConfig = moduleConfig.WithFSConfig(fsConfig) - } - return extism.PluginInstanceConfig{ModuleConfig: moduleConfig} } // instance creates a new plugin instance for the given context. // The context is used for cancellation - if cancelled during a call, // the module will be terminated and the instance becomes unusable. func (p *plugin) instance(ctx context.Context) (*extism.Plugin, error) { - instance, err := p.compiled.Instance(ctx, instanceConfig(p.fsConfig)) + instance, err := p.compiled.Instance(ctx, extism.PluginInstanceConfig{ + ModuleConfig: wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader), + }) if err != nil { return nil, err } @@ -86,58 +75,3 @@ 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().FindByUsername(ctx, 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_readonly_test.go b/plugins/manager_readonly_test.go deleted file mode 100644 index 0c88459c0..000000000 --- a/plugins/manager_readonly_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package plugins - -import ( - "context" - "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("Manager.LoadPlugins", func() { - var ( - ctx context.Context - mgr *Manager - repo *tests.MockPluginRepo - tmpDir string - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - // newManager builds a manager over rows the caller can corrupt, with no Subsonic router: a CLI - // has none, and Start would log.Fatal on that. - newManager := func(rows model.Plugins) *Manager { - DeferCleanup(configtest.SetupConfig()) - var err error - tmpDir, err = os.MkdirTemp("", "plugins-readonly-*") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { _ = os.RemoveAll(tmpDir) }) - - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = conf.NewDir(tmpDir) - - if rows == nil { - rows = installTestPlugins(tmpDir, "test-metadata-agent"+PackageExtension) - for i := range rows { - rows[i].AllUsers = true - } - } - repo = tests.CreateMockPluginRepo() - repo.Permitted = true - repo.SetData(rows) - m := &Manager{ - plugins: make(map[string]*plugin), - ds: &tests.MockDataStore{MockedPlugin: repo}, - metrics: noopMetricsRecorder{}, - } - DeferCleanup(func() { _ = m.Stop() }) - return m - } - - It("detects capabilities without a Subsonic router configured", func() { - mgr = newManager(nil) - - Expect(mgr.LoadPlugins(ctx, []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) - - Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("test-metadata-agent")) - }) - - Context("when a plugin cannot be loaded", func() { - brokenRows := func() model.Plugins { - return model.Plugins{{ - ID: "broken", Path: filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp"), - Enabled: true, AllUsers: true, - }} - } - - It("leaves the stored row untouched", func() { - mgr = newManager(brokenRows()) - - Expect(mgr.LoadPlugins(ctx, []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) - - stored, err := repo.Get(ctx, "broken") - Expect(err).ToNot(HaveOccurred()) - Expect(stored.Enabled).To(BeTrue(), "inspecting a plugin must never disable it") - Expect(stored.LastError).To(BeEmpty()) - }) - - // Without this the test above would pass for the wrong reason. Start cannot be used: it - // syncs the folder first, dropping a row whose file is missing before any load. - It("still disables it when not read-only", func() { - mgr = newManager(brokenRows()) - - Expect(mgr.loadEnabledPlugins(ctx)).To(Succeed()) - - stored, err := repo.Get(ctx, "broken") - Expect(err).ToNot(HaveOccurred()) - Expect(stored.Enabled).To(BeFalse()) - Expect(stored.LastError).ToNot(BeEmpty()) - }) - }) - - // Loading a plugin creates its host services — a KVStore or task queue database on disk — so a - // plugin that could never supply an image must not be instantiated just to be ignored. - It("does not load a plugin that is not in the agent list", func() { - mgr = newManager(nil) - - Expect(mgr.LoadPlugins(ctx, []string{"some-other-agent"}, false)).To(Succeed()) - - Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) - }) - - It("does nothing when no agents are configured", func() { - mgr = newManager(nil) - - Expect(mgr.LoadPlugins(ctx, nil, false)).To(Succeed()) - - Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) - // Not even the wazero cache: with nothing to load there is nothing to compile. - Expect(filepath.Join(tmpDir, "plugins")).ToNot(BeADirectory()) - }) - - It("does nothing when the plugin system is disabled", func() { - mgr = newManager(nil) - conf.Server.Plugins.Enabled = false - - Expect(mgr.LoadPlugins(ctx, []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) - - Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) - }) -}) diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index 33b4f81d4..f97069e74 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -12,7 +12,6 @@ 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" @@ -37,9 +36,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 @@ -65,7 +64,7 @@ func (m *Manager) addPluginToDB(ctx context.Context, repo model.PluginRepository CreatedAt: now, UpdatedAt: now, } - if err := repo.Put(ctx, newPlugin); err != nil { + if err := repo.Put(newPlugin); err != nil { return fmt.Errorf("adding plugin to DB: %w", err) } log.Info(ctx, "Discovered new plugin", "plugin", name) @@ -88,7 +87,7 @@ func (m *Manager) updatePluginInDB(ctx context.Context, repo model.PluginReposit dbPlugin.Enabled = false dbPlugin.LastError = "" dbPlugin.UpdatedAt = time.Now() - if err := repo.Put(ctx, dbPlugin); err != nil { + if err := repo.Put(dbPlugin); err != nil { return fmt.Errorf("updating plugin in DB: %w", err) } log.Info(ctx, "Plugin file changed", "plugin", dbPlugin.ID, "wasEnabled", wasEnabled) @@ -105,19 +104,9 @@ func (m *Manager) removePluginFromDB(ctx context.Context, repo model.PluginRepos log.Debug(ctx, "Plugin not loaded during removal", "plugin", pluginID, err) } } - if err := repo.Delete(ctx, pluginID); err != nil { + 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().Discard(ctx, 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 @@ -152,18 +141,14 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir()) continue } - name, ok := pluginIDFromPath(entry.Name()) - if !ok { - log.Warn(ctx, "Skipping plugin with unusable name", "name", entry.Name()) - continue - } + name := strings.TrimSuffix(entry.Name(), PackageExtension) filesOnDisk[name] = filepath.Join(folder, entry.Name()) } log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk)) // Get all plugins from DB - repo := m.ds.Plugin() - dbPlugins, err := repo.GetAll(adminCtx) + repo := m.ds.Plugin(adminCtx) + dbPlugins, err := repo.GetAll() if err != nil { return fmt.Errorf("reading plugins from DB: %w", err) } @@ -180,7 +165,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 @@ -192,7 +177,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { if dbPlugin.Path != path { dbPlugin.Path = path dbPlugin.UpdatedAt = now - if err := repo.Put(adminCtx, dbPlugin); err != nil { + if err := repo.Put(dbPlugin); err != nil { log.Error(ctx, "Failed to update plugin path in DB", "plugin", name, err) } } @@ -215,7 +200,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { } dbPlugin.Enabled = false } - if putErr := repo.Put(adminCtx, dbPlugin); putErr != nil { + if putErr := repo.Put(dbPlugin); putErr != nil { log.Error(ctx, "Failed to update plugin in DB", "plugin", name, err) } } @@ -225,12 +210,12 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { if !exists { // New plugin - add to DB as disabled - if err := m.addPluginToDB(adminCtx, repo, name, path, metadata); err != nil { + if err := m.addPluginToDB(ctx, repo, name, path, metadata); err != nil { log.Error(ctx, "Failed to add plugin to DB", "plugin", name, err) } } else { // Plugin changed - update DB - if err := m.updatePluginInDB(adminCtx, repo, dbPlugin, path, metadata); err != nil { + if err := m.updatePluginInDB(ctx, repo, dbPlugin, path, metadata); err != nil { log.Error(ctx, "Failed to update plugin in DB", "plugin", name, err) } } @@ -240,7 +225,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { // Remove plugins no longer on disk for _, dbPlugin := range pluginsInDB { - if err := m.removePluginFromDB(adminCtx, repo, dbPlugin); err != nil { + if err := m.removePluginFromDB(ctx, repo, dbPlugin); err != nil { log.Error(ctx, "Failed to delete plugin from DB", "plugin", dbPlugin.ID, err) } } diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go deleted file mode 100644 index e190abd34..000000000 --- a/plugins/manager_sync_test.go +++ /dev/null @@ -1,131 +0,0 @@ -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("syncPlugins", func() { - var ctx context.Context - var m *Manager - var repo *tests.MockPluginRepo - var folder string - - BeforeEach(func() { - ctx = GinkgoT().Context() - folder = GinkgoT().TempDir() - repo = tests.CreateMockPluginRepo() - repo.SetData(model.Plugins{}) - m = &Manager{ds: &tests.MockDataStore{MockedPlugin: repo}} - }) - - writePackage := func(name string) { - GinkgoHelper() - manifest := &Manifest{Name: "Test Plugin", Author: "Test Author", Version: "1.0.0"} - wasm := []byte{0x00, 0x61, 0x73, 0x6d} // Minimal wasm header - Expect(createTestPackage(filepath.Join(folder, name), manifest, wasm)).To(Succeed()) - } - - It("registers a plugin with a usable ID", func() { - writePackage("my-plugin" + PackageExtension) - - Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) - - _, err := repo.Get(ctx, "my-plugin") - Expect(err).ToNot(HaveOccurred()) - }) - - It("skips packages whose name yields a path-like ID", func() { - writePackage("." + PackageExtension) - writePackage(".." + PackageExtension) - - Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(all).To(BeEmpty()) - }) -}) - -var _ = Describe("removePluginFromDB", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - It("discards buffered scrobbles for the removed plugin", func() { - buffer := tests.CreateMockedScrobbleBufferRepo() - Expect(buffer.Enqueue(ctx, "my-plugin", "user1", "track1", time.Now())).To(Succeed()) - Expect(buffer.Enqueue(ctx, "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(ctx, "my-plugin") - Expect(err).To(MatchError(model.ErrNotFound)) - - remaining, err := buffer.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(remaining).To(Equal(int64(1))) - entry, err := buffer.Next(ctx, "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() { - scrobbler.Register("builtin-svc", func(model.DataStore) scrobbler.Scrobbler { return nil }) - buffer := tests.CreateMockedScrobbleBufferRepo() - Expect(buffer.Enqueue(ctx, "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(ctx) - 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_test.go b/plugins/manager_test.go index 6cf90994a..9b6f7ea39 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go index ce9e25a9d..4f266bda1 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.String() + folder := conf.Server.Plugins.Folder if folder == "" { return nil } @@ -89,11 +89,7 @@ func (m *Manager) handleWatcherEvent(event notify.EventInfo) { return } - pluginName, ok := pluginIDFromPath(path) - if !ok { - log.Warn(m.ctx, "Ignoring plugin file with unusable name", "path", path) - return - } + pluginName := strings.TrimSuffix(filepath.Base(path), PackageExtension) log.Trace(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path) @@ -150,25 +146,25 @@ func (m *Manager) processPluginEvent(pluginName string) { delete(m.debounceTimers, pluginName) m.debounceMu.Unlock() - folder := conf.Server.Plugins.Folder.String() + folder := conf.Server.Plugins.Folder ndpPath := filepath.Join(folder, pluginName+PackageExtension) action := determinePluginAction(ndpPath) log.Debug(m.ctx, "Plugin event action", "plugin", pluginName, "action", action, "path", ndpPath) ctx := adminContext(m.ctx) - repo := m.ds.Plugin() + repo := m.ds.Plugin(ctx) 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 } - dbPlugin, err := repo.Get(ctx, pluginName) + dbPlugin, err := repo.Get(pluginName) if err != nil { // Plugin not in DB yet, need full manifest extraction to add it metadata, extractErr := m.extractManifest(ndpPath) @@ -176,7 +172,7 @@ func (m *Manager) processPluginEvent(pluginName string) { log.Error(m.ctx, "Failed to extract manifest from new plugin", "plugin", pluginName, extractErr) return } - if addErr := m.addPluginToDB(ctx, repo, pluginName, ndpPath, metadata); addErr != nil { + if addErr := m.addPluginToDB(m.ctx, repo, pluginName, ndpPath, metadata); addErr != nil { log.Error(m.ctx, "Failed to add plugin to DB", "plugin", pluginName, addErr) } return @@ -198,23 +194,23 @@ func (m *Manager) processPluginEvent(pluginName string) { _ = m.unloadPlugin(pluginName) dbPlugin.Enabled = false } - _ = repo.Put(ctx, dbPlugin) + _ = repo.Put(dbPlugin) return } - if err := m.updatePluginInDB(ctx, repo, dbPlugin, ndpPath, metadata); err != nil { + if err := m.updatePluginInDB(m.ctx, repo, dbPlugin, ndpPath, metadata); err != nil { log.Error(m.ctx, "Failed to update plugin in DB", "plugin", pluginName, err) } case actionRemove: // File removed - unload if enabled, delete from DB - dbPlugin, err := repo.Get(ctx, pluginName) + dbPlugin, err := repo.Get(pluginName) if err != nil { log.Debug(m.ctx, "Removed plugin not in DB", "plugin", pluginName) return } - if err := m.removePluginFromDB(ctx, repo, dbPlugin); err != nil { + if err := m.removePluginFromDB(m.ctx, repo, dbPlugin); err != nil { log.Error(m.ctx, "Failed to delete plugin from DB", "plugin", pluginName, err) } } diff --git a/plugins/manager_watcher_test.go b/plugins/manager_watcher_test.go index 17d9489dc..5b5ffca02 100644 --- a/plugins/manager_watcher_test.go +++ b/plugins/manager_watcher_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -31,7 +33,7 @@ var _ = Describe("Plugin Watcher", func() { _ = manager.unloadPlugin("test-metadata-agent") _ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension)) // Also remove from DB so tests start with a clean slate - _ = manager.ds.Plugin().Delete(ctx, "test-metadata-agent") + _ = manager.ds.Plugin(ctx).Delete("test-metadata-agent") }) // Helper to copy test plugin into the temp folder @@ -51,7 +53,7 @@ var _ = Describe("Plugin Watcher", func() { // Clean up: unload plugin if loaded, remove copied file, delete from DB _ = manager.unloadPlugin("test-metadata-agent") _ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension)) - _ = manager.ds.Plugin().Delete(ctx, "test-metadata-agent") + _ = manager.ds.Plugin(ctx).Delete("test-metadata-agent") }) It("adds plugin to DB when file exists", func() { @@ -62,8 +64,8 @@ var _ = Describe("Plugin Watcher", func() { Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent")) // Verify it was added to DB - repo := manager.ds.Plugin() - plugin, err := repo.Get(ctx, "test-metadata-agent") + repo := manager.ds.Plugin(ctx) + plugin, err := repo.Get("test-metadata-agent") Expect(err).ToNot(HaveOccurred()) Expect(plugin.ID).To(Equal("test-metadata-agent")) Expect(plugin.Enabled).To(BeFalse()) @@ -80,11 +82,11 @@ var _ = Describe("Plugin Watcher", func() { // Modify the stored SHA256 in DB to simulate a file change // (In reality, the file would have different content) - repo := manager.ds.Plugin() - plugin, err := repo.Get(ctx, "test-metadata-agent") + repo := manager.ds.Plugin(ctx) + plugin, err := repo.Get("test-metadata-agent") Expect(err).ToNot(HaveOccurred()) plugin.SHA256 = "different-hash-to-simulate-change" - err = repo.Put(ctx, plugin) + err = repo.Put(plugin) Expect(err).ToNot(HaveOccurred()) // Simulate modification - the plugin should be disabled and unloaded @@ -94,7 +96,7 @@ var _ = Describe("Plugin Watcher", func() { Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent")) // But still in DB (just disabled) - plugin, err = repo.Get(ctx, "test-metadata-agent") + plugin, err = repo.Get("test-metadata-agent") Expect(err).ToNot(HaveOccurred()) Expect(plugin.Enabled).To(BeFalse()) }) @@ -115,8 +117,8 @@ var _ = Describe("Plugin Watcher", func() { Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent")) // And removed from DB - repo := manager.ds.Plugin() - _, err = repo.Get(ctx, "test-metadata-agent") + repo := manager.ds.Plugin(ctx) + _, err = repo.Get("test-metadata-agent") Expect(err).To(HaveOccurred()) }) }) diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 056a1caed..c15a3bf3d 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -34,6 +34,9 @@ "permissions": { "$ref": "#/$defs/Permissions" }, + "experimental": { + "$ref": "#/$defs/Experimental" + }, "config": { "$ref": "#/$defs/ConfigDefinition" } @@ -55,6 +58,27 @@ } } }, + "Experimental": { + "type": "object", + "description": "Experimental features that may change or be removed in future versions", + "additionalProperties": false, + "properties": { + "threads": { + "$ref": "#/$defs/ThreadsFeature" + } + } + }, + "ThreadsFeature": { + "type": "object", + "description": "Enable experimental WebAssembly threads support", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why threads support is needed" + } + } + }, "Permissions": { "type": "object", "description": "Permissions required by the plugin", @@ -89,15 +113,6 @@ }, "taskqueue": { "$ref": "#/$defs/TaskQueuePermission" - }, - "matcher": { - "$ref": "#/$defs/MatcherPermission" - }, - "storage": { - "$ref": "#/$defs/StoragePermission" - }, - "scrobbleRetriever": { - "$ref": "#/$defs/ScrobbleRetrieverPermission" } } }, @@ -134,7 +149,7 @@ }, "requiredHosts": { "type": "array", - "description": "List of required host patterns for HTTP requests (e.g., 'api.example.com', '*.musicbrainz.org'). A named host alone can't reach a private address; also list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that", + "description": "List of required host patterns for HTTP requests (e.g., 'api.example.com', '*.musicbrainz.org')", "items": { "type": "string" } @@ -174,7 +189,7 @@ }, "requiredHosts": { "type": "array", - "description": "List of required host patterns for WebSocket connections (e.g., 'api.example.com', '*.musicbrainz.org'). Required: with no entries every connection is blocked. A named host alone can't reach a private address; also list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that", + "description": "List of required host patterns for WebSocket connections (e.g., 'api.example.com', '*.musicbrainz.org')", "items": { "type": "string" } @@ -239,39 +254,6 @@ "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" - } - } - }, - "StoragePermission": { - "type": "object", - "description": "Storage permissions for enabling persistent read-write storage exclusively for the plugin", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why storage access is needed" - } - } - }, - "ScrobbleRetrieverPermission": { - "type": "object", - "description": "Scrobble retriever permissions for retrieving scrobbles from users", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why scrobble retriever access is needed" - } - } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index 5c0d91f51..7484718e3 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -3,37 +3,10 @@ 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. @@ -53,22 +26,10 @@ func ParseManifest(data []byte) (*Manifest, error) { // This validates rules like "SubsonicAPI permission requires users permission". func (m *Manifest) Validate() error { // SubsonicAPI permission requires users permission - if m.Permissions != nil && m.Permissions.Users == nil { - if m.Permissions.Subsonicapi != nil { + if m.Permissions != nil && m.Permissions.Subsonicapi != nil { + if m.Permissions.Users == nil { return fmt.Errorf("'subsonicapi' permission requires 'users' permission to be declared") } - - if m.Permissions.ScrobbleRetriever != nil { - return fmt.Errorf("'scrobbleRetriever' permission requires 'users' permission to be declared") - } - } - - // 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 @@ -121,13 +82,14 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { return nil } +// HasExperimentalThreads returns true if the manifest requests experimental threads support. +func (m *Manifest) HasExperimentalThreads() bool { + return m.Experimental != nil && m.Experimental.Threads != nil +} + // HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries. func (m *Manifest) HasLibraryFilesystemPermission() bool { return m.Permissions != nil && m.Permissions.Library != nil && m.Permissions.Library.Filesystem } - -func (m *Manifest) HasStoragePermission() bool { - return m.Permissions != nil && m.Permissions.Storage != nil -} diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index b5e9afd96..efe93e05f 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -45,14 +45,19 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error { return nil } +// Experimental features that may change or be removed in future versions +type Experimental struct { + // Threads corresponds to the JSON schema field "threads". + Threads *ThreadsFeature `json:"threads,omitempty" yaml:"threads,omitempty" mapstructure:"threads,omitempty"` +} + // HTTP access permissions for a plugin type HTTPPermission struct { // Explanation for why HTTP access is needed Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` // List of required host patterns for HTTP requests (e.g., 'api.example.com', - // '*.musicbrainz.org'). A named host alone can't reach a private address; also - // list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that + // '*.musicbrainz.org') RequiredHosts []string `json:"requiredHosts,omitempty" yaml:"requiredHosts,omitempty" mapstructure:"requiredHosts,omitempty"` } @@ -104,6 +109,9 @@ type Manifest struct { // A brief description of what the plugin does Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` + // Experimental corresponds to the JSON schema field "experimental". + Experimental *Experimental `json:"experimental,omitempty" yaml:"experimental,omitempty" mapstructure:"experimental,omitempty"` + // The display name of the plugin Name string `json:"name" yaml:"name" mapstructure:"name"` @@ -150,12 +158,6 @@ 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". @@ -173,18 +175,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"` - // ScrobbleRetriever corresponds to the JSON schema field "scrobbleRetriever". - ScrobbleRetriever *ScrobbleRetrieverPermission `json:"scrobbleRetriever,omitempty" yaml:"scrobbleRetriever,omitempty" mapstructure:"scrobbleRetriever,omitempty"` - - // Storage corresponds to the JSON schema field "storage". - Storage *StoragePermission `json:"storage,omitempty" yaml:"storage,omitempty" mapstructure:"storage,omitempty"` - // Subsonicapi corresponds to the JSON schema field "subsonicapi". Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"` @@ -204,19 +197,6 @@ type SchedulerPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } -// Scrobble retriever permissions for retrieving scrobbles from users -type ScrobbleRetrieverPermission struct { - // Explanation for why scrobble retriever access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// Storage permissions for enabling persistent read-write storage exclusively for -// the plugin -type StoragePermission struct { - // Explanation for why storage access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - // SubsonicAPI service permissions. Requires 'users' permission to be declared. type SubsonicAPIPermission struct { // Explanation for why SubsonicAPI access is needed @@ -253,6 +233,12 @@ func (j *TaskQueuePermission) UnmarshalJSON(value []byte) error { return nil } +// Enable experimental WebAssembly threads support +type ThreadsFeature struct { + // Explanation for why threads support is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // Users service permissions for accessing user information type UsersPermission struct { // Explanation for why users access is needed @@ -265,8 +251,6 @@ type WebSocketPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` // List of required host patterns for WebSocket connections (e.g., - // 'api.example.com', '*.musicbrainz.org'). Required: with no entries every - // connection is blocked. A named host alone can't reach a private address; also - // list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that + // 'api.example.com', '*.musicbrainz.org') RequiredHosts []string `json:"requiredHosts,omitempty" yaml:"requiredHosts,omitempty" mapstructure:"requiredHosts,omitempty"` } diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index bfd8bcac1..c45a480eb 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -117,6 +117,77 @@ var _ = Describe("Manifest", func() { }) }) + Describe("HasExperimentalThreads", func() { + It("returns false when no experimental section", func() { + m := &Manifest{} + Expect(m.HasExperimentalThreads()).To(BeFalse()) + }) + + It("returns false when experimental section has no threads", func() { + m := &Manifest{ + Experimental: &Experimental{}, + } + Expect(m.HasExperimentalThreads()).To(BeFalse()) + }) + + It("returns true when threads feature is present", func() { + m := &Manifest{ + Experimental: &Experimental{ + Threads: &ThreadsFeature{}, + }, + } + Expect(m.HasExperimentalThreads()).To(BeTrue()) + }) + + It("returns true when threads feature has a reason", func() { + reason := "Required for concurrent processing" + m := &Manifest{ + Experimental: &Experimental{ + Threads: &ThreadsFeature{ + Reason: &reason, + }, + }, + } + Expect(m.HasExperimentalThreads()).To(BeTrue()) + }) + + It("parses experimental.threads from JSON", func() { + data := []byte(`{ + "name": "Threaded Plugin", + "author": "Test Author", + "version": "1.0.0", + "experimental": { + "threads": { + "reason": "To use multi-threaded WASM module" + } + } + }`) + + var m Manifest + err := json.Unmarshal(data, &m) + Expect(err).ToNot(HaveOccurred()) + Expect(m.HasExperimentalThreads()).To(BeTrue()) + Expect(m.Experimental.Threads.Reason).ToNot(BeNil()) + Expect(*m.Experimental.Threads.Reason).To(Equal("To use multi-threaded WASM module")) + }) + + It("parses experimental.threads without reason from JSON", func() { + data := []byte(`{ + "name": "Threaded Plugin", + "author": "Test Author", + "version": "1.0.0", + "experimental": { + "threads": {} + } + }`) + + var m Manifest + err := json.Unmarshal(data, &m) + Expect(err).ToNot(HaveOccurred()) + Expect(m.HasExperimentalThreads()).To(BeTrue()) + }) + }) + Describe("ParseManifest", func() { It("parses a valid manifest with users permission", func() { data := []byte(`{ @@ -191,67 +262,6 @@ 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 with scrobbleRetriever and users permissions", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Permissions: &Permissions{ - ScrobbleRetriever: &ScrobbleRetrieverPermission{}, - Users: &UsersPermission{}, - }, - } - - err := m.Validate() - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns error when scrobbleRetriever without users permission", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Permissions: &Permissions{ - ScrobbleRetriever: &ScrobbleRetrieverPermission{}, - }, - } - - err := m.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("scrobbleRetriever")) - }) - It("validates manifest without subsonicapi", func() { m := &Manifest{ Name: "Test", @@ -455,27 +465,3 @@ 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 17062ba6b..52542300c 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -6,7 +6,6 @@ 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" ) @@ -50,18 +49,6 @@ func newMetadataAgent(p *plugin) *MetadataAgent { return &MetadataAgent{name: p.name, plugin: p} } -// agentErr keeps a plugin fault distinguishable from a definitive miss: a method the plugin -// simply does not implement has answered, so it must not count against a caller's back-off. -func agentErr(err error) error { - if errors.Is(err, errNotImplemented) || errors.Is(err, errFunctionNotFound) { - return errors.Join(agents.ErrNotFound, err) - } - if retryLater, ok := parseRetryLater(agentRetryLaterRe, err.Error()); ok { - return errors.Join(retryLater, err) - } - return err -} - // MetadataAgent is an adapter that wraps an Extism plugin and implements // the agents interfaces for metadata retrieval. type MetadataAgent struct { @@ -81,7 +68,7 @@ func (a *MetadataAgent) GetArtistMBID(ctx context.Context, id string, name strin input := capabilities.ArtistMBIDRequest{ID: id, Name: name} result, err := callPluginFunction[capabilities.ArtistMBIDRequest, *capabilities.ArtistMBIDResponse](ctx, a.plugin, FuncGetArtistMBID, input) if err != nil { - return "", agentErr(err) + return "", errors.Join(agents.ErrNotFound, err) } if result == nil || result.MBID == "" { @@ -96,7 +83,7 @@ func (a *MetadataAgent) GetArtistURL(ctx context.Context, id, name, mbid string) input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistURLResponse](ctx, a.plugin, FuncGetArtistURL, input) if err != nil { - return "", agentErr(err) + return "", errors.Join(agents.ErrNotFound, err) } if result == nil || result.URL == "" { return "", agents.ErrNotFound @@ -109,7 +96,7 @@ func (a *MetadataAgent) GetArtistBiography(ctx context.Context, id, name, mbid s input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistBiographyResponse](ctx, a.plugin, FuncGetArtistBiography, input) if err != nil { - return "", agentErr(err) + return "", errors.Join(agents.ErrNotFound, err) } if result == nil || result.Biography == "" { @@ -124,7 +111,7 @@ func (a *MetadataAgent) GetSimilarArtists(ctx context.Context, id, name, mbid st input := capabilities.SimilarArtistsRequest{ID: id, Name: name, MBID: mbid, Limit: int32(limit)} result, err := callPluginFunction[capabilities.SimilarArtistsRequest, *capabilities.SimilarArtistsResponse](ctx, a.plugin, FuncGetSimilarArtists, input) if err != nil { - return nil, agentErr(err) + return nil, errors.Join(agents.ErrNotFound, err) } if result == nil || len(result.Artists) == 0 { @@ -144,7 +131,7 @@ func (a *MetadataAgent) GetArtistImages(ctx context.Context, id, name, mbid stri input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistImagesResponse](ctx, a.plugin, FuncGetArtistImages, input) if err != nil { - return nil, agentErr(err) + return nil, errors.Join(agents.ErrNotFound, err) } if result == nil || len(result.Images) == 0 { @@ -164,7 +151,7 @@ func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, m input := capabilities.TopSongsRequest{ID: id, Name: artistName, MBID: mbid, Count: int32(count)} result, err := callPluginFunction[capabilities.TopSongsRequest, *capabilities.TopSongsResponse](ctx, a.plugin, FuncGetArtistTopSongs, input) if err != nil { - return nil, agentErr(err) + return nil, errors.Join(agents.ErrNotFound, err) } if result == nil || len(result.Songs) == 0 { @@ -179,7 +166,7 @@ func (a *MetadataAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid str input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid} result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumInfoResponse](ctx, a.plugin, FuncGetAlbumInfo, input) if err != nil { - return nil, agentErr(err) + return nil, errors.Join(agents.ErrNotFound, err) } if result == nil { @@ -199,7 +186,7 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid} result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumImagesResponse](ctx, a.plugin, FuncGetAlbumImages, input) if err != nil { - return nil, agentErr(err) + return nil, errors.Join(agents.ErrNotFound, err) } if result == nil || len(result.Images) == 0 { @@ -217,7 +204,7 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s func callSimilarSongsPluginFunction[T any](ctx context.Context, plugin *plugin, funcName string, input T) ([]agents.Song, error) { result, err := callPluginFunction[T, *capabilities.SimilarSongsResponse](ctx, plugin, funcName, input) if err != nil { - return nil, agentErr(err) + return nil, err } if result == nil || len(result.Songs) == 0 { return nil, agents.ErrNotFound @@ -240,34 +227,23 @@ 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. 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}} - } +// songRefToAgentSong converts a single SongRef to agents.Song +func songRefToAgentSong(s capabilities.SongRef) agents.Song { return agents.Song{ - ID: s.ID, - Name: s.Name, - MBID: s.MBID, - ISRC: s.ISRC, - Artists: artists, - Album: s.Album, - AlbumMBID: s.AlbumMBID, - Duration: s.DurationInMs(), + 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), } } // songRefsToAgentSongs converts a slice of SongRef to agents.Song -func songRefsToAgentSongs(refs []types.SongRef) []agents.Song { +func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song { return slice.Map(refs, songRefToAgentSong) } diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index 0a7b6c1e2..067ae80ca 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -1,62 +1,13 @@ +//go:build !windows + package plugins import ( - "errors" - "fmt" - "time" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/plugins/capabilities" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// The partial-metadata-agent fixture registers through the Go PDK, which exports every method -// and answers -2, so only errNotImplemented reaches agentErr through a real plugin. A plugin -// that omits the export entirely yields errFunctionNotFound, covered here directly. -var _ = Describe("agentErr", func() { - DescribeTable("classifies a plugin error as a miss or a fault", - func(err error, wantMiss bool) { - got := agentErr(err) - Expect(errors.Is(got, agents.ErrNotFound)).To(Equal(wantMiss)) - Expect(got).To(MatchError(err), "the underlying reason must survive for diagnostics") - }, - Entry("an unimplemented method is a miss", - fmt.Errorf("%w: %s", errNotImplemented, FuncGetArtistImages), true), - Entry("a missing export is a miss", - fmt.Errorf("%w: %s", errFunctionNotFound, FuncGetArtistImages), true), - Entry("a call failure is a fault", - fmt.Errorf("plugin call failed: %w", errors.New("returned status 429")), false), - Entry("a non-zero exit is a fault", - errors.New("plugin call exited with code 1"), false), - ) - - DescribeTable("agentErr retry-later", - func(msg string, wantDelay time.Duration) { - err := agentErr(errors.New(msg)) - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - retry, _ := errors.AsType[*agents.RetryLaterError](err) - d := retry.RetryIn - Expect(d).To(Equal(wantDelay)) - }, - Entry("bare token", "agent(retry_later)", time.Duration(0)), - Entry("with seconds", "agent(retry_later:120)", 120*time.Second), - Entry("capped at 1h", "agent(retry_later:999999)", time.Hour), - // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. - Entry("capped before it can overflow", "agent(retry_later:18446744074)", time.Hour), - ) - - It("leaves other plugin errors untouched", func() { - orig := errors.New("some plugin failure") - Expect(agentErr(orig)).To(Equal(orig)) - }) - - It("does not treat a superstring token as a throttle", func() { - orig := errors.New("useragent(retry_later)") - Expect(agentErr(orig)).To(Equal(orig)) - }) -}) - var _ = Describe("MetadataAgent", Ordered, func() { var agent agents.Interface @@ -167,8 +118,7 @@ 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].Artists).To(HaveLen(1)) - Expect(songs[0].Artists[0].Name).To(Equal("The Beatles")) + Expect(songs[0].Artist).To(Equal("The Beatles")) }) }) @@ -214,57 +164,82 @@ var _ = Describe("MetadataAgent error handling", Ordered, func() { Expect(ok).To(BeTrue()) }) - DescribeTable("surfaces the plugin failure, not a definitive not-found", - func(call func() error) { - err := call() - Expect(err).To(MatchError(ContainSubstring("simulated plugin error"))) - Expect(err).ToNot(MatchError(agents.ErrNotFound)) - }, - Entry("GetArtistMBID", func() error { - _, err := errorAgent.(agents.ArtistMBIDRetriever).GetArtistMBID(GinkgoT().Context(), "artist-1", "Test") - return err - }), - Entry("GetArtistURL", func() error { - _, err := errorAgent.(agents.ArtistURLRetriever).GetArtistURL(GinkgoT().Context(), "artist-1", "Test", "mbid") - return err - }), - Entry("GetArtistBiography", func() error { - _, err := errorAgent.(agents.ArtistBiographyRetriever).GetArtistBiography(GinkgoT().Context(), "artist-1", "Test", "mbid") - return err - }), - Entry("GetSimilarArtists", func() error { - _, err := errorAgent.(agents.ArtistSimilarRetriever).GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) - return err - }), - Entry("GetArtistImages", func() error { - _, err := errorAgent.(agents.ArtistImageRetriever).GetArtistImages(GinkgoT().Context(), "artist-1", "Test", "mbid") - return err - }), - Entry("GetArtistTopSongs", func() error { - _, err := errorAgent.(agents.ArtistTopSongsRetriever).GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) - return err - }), - Entry("GetAlbumInfo", func() error { - _, err := errorAgent.(agents.AlbumInfoRetriever).GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") - return err - }), - Entry("GetAlbumImages", func() error { - _, err := errorAgent.(agents.AlbumImageRetriever).GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") - return err - }), - Entry("GetSimilarSongsByTrack", func() error { - _, err := errorAgent.(agents.SimilarSongsByTrackRetriever).GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) - return err - }), - Entry("GetSimilarSongsByAlbum", func() error { - _, err := errorAgent.(agents.SimilarSongsByAlbumRetriever).GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) - return err - }), - Entry("GetSimilarSongsByArtist", func() error { - _, err := errorAgent.(agents.SimilarSongsByArtistRetriever).GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) - return err - }), - ) + It("returns error from GetArtistMBID", func() { + retriever := errorAgent.(agents.ArtistMBIDRetriever) + _, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetArtistURL", func() { + retriever := errorAgent.(agents.ArtistURLRetriever) + _, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "Test", "mbid") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetArtistBiography", func() { + retriever := errorAgent.(agents.ArtistBiographyRetriever) + _, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test", "mbid") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetArtistImages", func() { + retriever := errorAgent.(agents.ArtistImageRetriever) + _, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "Test", "mbid") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetSimilarArtists", func() { + retriever := errorAgent.(agents.ArtistSimilarRetriever) + _, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetArtistTopSongs", func() { + retriever := errorAgent.(agents.ArtistTopSongsRetriever) + _, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetAlbumInfo", func() { + retriever := errorAgent.(agents.AlbumInfoRetriever) + _, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetAlbumImages", func() { + retriever := errorAgent.(agents.AlbumImageRetriever) + _, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetSimilarSongsByTrack", func() { + retriever := errorAgent.(agents.SimilarSongsByTrackRetriever) + _, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetSimilarSongsByAlbum", func() { + retriever := errorAgent.(agents.SimilarSongsByAlbumRetriever) + _, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from GetSimilarSongsByArtist", func() { + retriever := errorAgent.(agents.SimilarSongsByArtistRetriever) + _, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) }) var _ = Describe("MetadataAgent partial implementation", Ordered, func() { @@ -291,81 +266,66 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, func() { Expect(bio).To(Equal("Partial agent biography for Test Artist")) }) - // An unimplemented optional method is a definitive miss. Reported as a fault it would - // count against the artwork circuit breaker and keep the item in the retry queue. - DescribeTable("reports an unimplemented method as a definitive not-found", - func(call func() error) { - err := call() - Expect(err).To(MatchError(errNotImplemented)) - Expect(err).To(MatchError(agents.ErrNotFound)) - }, - Entry("GetArtistMBID", func() error { - _, err := partialAgent.(agents.ArtistMBIDRetriever).GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist") - return err - }), - Entry("GetArtistURL", func() error { - _, err := partialAgent.(agents.ArtistURLRetriever).GetArtistURL(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - return err - }), - Entry("GetArtistImages", func() error { - _, err := partialAgent.(agents.ArtistImageRetriever).GetArtistImages(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - return err - }), - Entry("GetSimilarArtists", func() error { - _, err := partialAgent.(agents.ArtistSimilarRetriever).GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) - return err - }), - Entry("GetArtistTopSongs", func() error { - _, err := partialAgent.(agents.ArtistTopSongsRetriever).GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) - return err - }), - Entry("GetAlbumInfo", func() error { - _, err := partialAgent.(agents.AlbumInfoRetriever).GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") - return err - }), - Entry("GetAlbumImages", func() error { - _, err := partialAgent.(agents.AlbumImageRetriever).GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") - return err - }), - Entry("GetSimilarSongsByTrack", func() error { - _, err := partialAgent.(agents.SimilarSongsByTrackRetriever).GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) - return err - }), - Entry("GetSimilarSongsByAlbum", func() error { - _, err := partialAgent.(agents.SimilarSongsByAlbumRetriever).GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) - return err - }), - Entry("GetSimilarSongsByArtist", func() error { - _, err := partialAgent.(agents.SimilarSongsByArtistRetriever).GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) - return err - }), - ) -}) + It("returns ErrNotFound for unimplemented method (GetArtistMBID)", func() { + retriever := partialAgent.(agents.ArtistMBIDRetriever) + _, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist") + 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("returns ErrNotFound for unimplemented method (GetArtistURL)", func() { + retriever := partialAgent.(agents.ArtistURLRetriever) + _, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") + Expect(err).To(MatchError(errNotImplemented)) }) - 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("returns ErrNotFound for unimplemented method (GetArtistImages)", func() { + retriever := partialAgent.(agents.ArtistImageRetriever) + _, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") + Expect(err).To(MatchError(errNotImplemented)) }) - 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("returns ErrNotFound for unimplemented method (GetSimilarArtists)", func() { + retriever := partialAgent.(agents.ArtistSimilarRetriever) + _, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) + Expect(err).To(MatchError(errNotImplemented)) + }) - 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()) + + It("returns ErrNotFound for unimplemented method (GetArtistTopSongs)", func() { + retriever := partialAgent.(agents.ArtistTopSongsRetriever) + _, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) + Expect(err).To(MatchError(errNotImplemented)) + + }) + + It("returns ErrNotFound for unimplemented method (GetAlbumInfo)", func() { + retriever := partialAgent.(agents.AlbumInfoRetriever) + _, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") + Expect(err).To(MatchError(errNotImplemented)) + + }) + + It("returns ErrNotFound for unimplemented method (GetAlbumImages)", func() { + retriever := partialAgent.(agents.AlbumImageRetriever) + _, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") + Expect(err).To(MatchError(errNotImplemented)) + }) + + It("returns ErrNotFound for unimplemented method (GetSimilarSongsByTrack)", func() { + retriever := partialAgent.(agents.SimilarSongsByTrackRetriever) + _, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) + Expect(err).To(MatchError(errNotImplemented)) + }) + + It("returns ErrNotFound for unimplemented method (GetSimilarSongsByAlbum)", func() { + retriever := partialAgent.(agents.SimilarSongsByAlbumRetriever) + _, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) + Expect(err).To(MatchError(errNotImplemented)) + }) + + It("returns ErrNotFound for unimplemented method (GetSimilarSongsByArtist)", func() { + retriever := partialAgent.(agents.SimilarSongsByArtistRetriever) + _, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) + Expect(err).To(MatchError(errNotImplemented)) }) }) diff --git a/plugins/package.go b/plugins/package.go index 77cb6e5ac..475761231 100644 --- a/plugins/package.go +++ b/plugins/package.go @@ -5,8 +5,6 @@ import ( "errors" "fmt" "io" - "path/filepath" - "strings" ) const ( @@ -20,26 +18,6 @@ const ( wasmFileName = "plugin.wasm" ) -// validPluginID reports whether id is usable. It names a directory under -// DataFolder/plugins, so a path-like one would point at another plugin's data. -func validPluginID(id string) bool { - if id == "." || id == ".." || strings.ContainsAny(id, `/\`) || !filepath.IsLocal(id) { - return false - } - // Windows drops trailing dots and spaces, so "foo." and "foo" would end up - // sharing a directory - return strings.TrimRight(id, ". ") == id -} - -// pluginIDFromPath derives the plugin ID from a package path. -func pluginIDFromPath(path string) (string, bool) { - id := strings.TrimSuffix(filepath.Base(path), PackageExtension) - if !validPluginID(id) { - return "", false - } - return id, true -} - // ndpPackage represents a loaded .ndp plugin package. // It contains the manifest and wasm bytes read from the archive. type ndpPackage struct { @@ -94,10 +72,9 @@ func openPackage(ndpPath string) (*ndpPackage, error) { }, nil } -// 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) { +// 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) { // 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 1539d8df7..fa76ddd94 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -2,11 +2,8 @@ package plugins import ( "archive/zip" - "bytes" "encoding/json" - "errors" "fmt" - "io" "os" "path/filepath" @@ -14,36 +11,6 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("pluginIDFromPath", func() { - DescribeTable("derives the ID from the package filename", - func(path, expected string) { - id, ok := pluginIDFromPath(path) - Expect(ok).To(BeTrue()) - Expect(id).To(Equal(expected)) - }, - Entry("plain name", "/plugins/discord-rich-presence.ndp", "discord-rich-presence"), - Entry("name with spaces", "/plugins/My Plugin.ndp", "My Plugin"), - Entry("leading dot", "/plugins/.hidden.ndp", ".hidden"), - Entry("dots inside", "/plugins/v1.2.3.ndp", "v1.2.3"), - ) - - // The ID becomes a directory name under DataFolder/plugins, so a path-like - // one would let a plugin reach another plugin's data - DescribeTable("rejects IDs that are unsafe as a directory name", - func(path string) { - _, ok := pluginIDFromPath(path) - Expect(ok).To(BeFalse()) - }, - Entry("empty", "/plugins/.ndp"), - Entry("current directory", "/plugins/..ndp"), - Entry("parent directory", "/plugins/...ndp"), - // Windows drops trailing dots and spaces, so these would share a - // directory with "foo" - Entry("trailing dot", "/plugins/foo..ndp"), - Entry("trailing space", "/plugins/foo .ndp"), - ) -}) - var _ = Describe("ndpPackage", func() { var tmpDir string @@ -165,69 +132,67 @@ var _ = Describe("ndpPackage", func() { }) }) - Describe("ReadManifest", func() { - It("parses the manifest from a package that also contains wasm", func() { + Describe("readManifest", func() { + It("should read only the manifest without loading 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: new("A test plugin"), + Description: &desc, } + wasmBytes := make([]byte, 1024*1024) // 1MB of zeros - err := createTestPackage(ndpPath, manifest, nil) + err := createTestPackage(ndpPath, manifest, wasmBytes) 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("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() { + It("should return error for missing manifest", func() { ndpPath := filepath.Join(tmpDir, "no-manifest.ndp") + f, err := os.Create(ndpPath) Expect(err).ToNot(HaveOccurred()) defer f.Close() - zw := newTestZipWriter(f) - Expect(zw.addFile("plugin.wasm", []byte{0x00})).To(Succeed()) - Expect(zw.close()).To(Succeed()) - _, err = ReadManifest(ndpPath) + zw := newTestZipWriter(f) + err = zw.addFile("plugin.wasm", []byte{0x00}) + Expect(err).ToNot(HaveOccurred()) + err = zw.close() + Expect(err).ToNot(HaveOccurred()) + + _, err = readManifest(ndpPath) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("missing manifest.json")) }) + }) - 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 + Describe("ComputePackageSHA256", func() { + It("should compute consistent hash for same file", func() { + ndpPath := filepath.Join(tmpDir, "test.ndp") manifest := &Manifest{ - Name: "X", - Author: "me", - Version: "1.0.0", - Permissions: &Permissions{Subsonicapi: &SubsonicAPIPermission{}}, + Name: "Test Plugin", + Author: "Test Author", + Version: "1.0.0", } - err := createTestPackage(ndp, manifest, nil) + wasmBytes := []byte{0x00, 0x61, 0x73, 0x6d} + + err := createTestPackage(ndpPath, manifest, wasmBytes) Expect(err).ToNot(HaveOccurred()) - _, err = ReadManifest(ndp) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("subsonicapi")) - Expect(err.Error()).To(ContainSubstring("users")) + 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 }) }) }) @@ -269,14 +234,6 @@ func (h *testZipHelper) close() error { // createTestPackage creates an .ndp package file from a manifest and wasm bytes. // This is primarily used for testing. func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) error { - manifestBytes, err := json.Marshal(manifest) - if err != nil { - return fmt.Errorf("marshaling manifest: %w", err) - } - return writeNdp(ndpPath, bytes.NewReader(manifestBytes), bytes.NewReader(wasmBytes)) -} - -func writeNdp(ndpPath string, manifest, wasm io.Reader) error { f, err := os.Create(ndpPath) if err != nil { return fmt.Errorf("creating package file: %w", err) @@ -284,23 +241,30 @@ func writeNdp(ndpPath string, manifest, wasm io.Reader) error { defer f.Close() zw := zip.NewWriter(f) - add := func(name string, r io.Reader) error { - w, err := zw.Create(name) - if err != nil { - return fmt.Errorf("creating %s in package: %w", name, err) - } - if _, err := io.Copy(w, r); err != nil { - return fmt.Errorf("writing %s: %w", name, err) - } - return nil + defer zw.Close() + + // Write manifest.json + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("marshaling manifest: %w", err) } - // Entry order is fixed: the loader hashes the package bytes, so they must - // be reproducible across rebuilds. - if err := add(manifestFileName, manifest); err != nil { - return err + + mw, err := zw.Create(manifestFileName) + if err != nil { + return fmt.Errorf("creating manifest in zip: %w", err) } - if err := add(wasmFileName, wasm); err != nil { - return err + if _, err := mw.Write(manifestBytes); err != nil { + return fmt.Errorf("writing manifest: %w", err) } - return errors.Join(zw.Close(), f.Close()) + + // Write plugin.wasm + ww, err := zw.Create(wasmFileName) + if err != nil { + return fmt.Errorf("creating wasm in zip: %w", err) + } + if _, err := ww.Write(wasmBytes); err != nil { + return fmt.Errorf("writing wasm: %w", err) + } + + return nil } diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index 9063f2bd0..5781a04c1 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -41,10 +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. - - ScrobbleRetriever: allows a plugin to retrieve scrobbles for one or more authorized users. - - Storage: provides access to a plugin-specific directory with read/write permissions - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - Task: provides persistent task queues for plugins. - Users: provides access to user information for plugins. diff --git a/plugins/pdk/go/host/mock_nil_returns_test.go b/plugins/pdk/go/host/mock_nil_returns_test.go deleted file mode 100644 index ea5f334f1..000000000 --- a/plugins/pdk/go/host/mock_nil_returns_test.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build !wasip1 - -package host - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -// Return(nil, ...) models "no result"; the generated accessors must yield the -// zero value instead of panicking on the type assertion. -func TestMockNilPointerReturn(t *testing.T) { - HTTPMock.On("Send", HTTPRequest{URL: "http://nil.example"}).Return(nil, nil) - - resp, err := HTTPSend(HTTPRequest{URL: "http://nil.example"}) - require.NoError(t, err) - require.Nil(t, resp) -} - -func TestMockNilSliceReturn(t *testing.T) { - LibraryMock.On("GetAllLibraries").Return(nil, nil) - - libs, err := LibraryGetAllLibraries() - require.NoError(t, err) - require.Nil(t, libs) -} diff --git a/plugins/pdk/go/host/nd_host_artwork_stub.go b/plugins/pdk/go/host/nd_host_artwork_stub.go index 3b81e0d6b..aa41e440c 100644 --- a/plugins/pdk/go/host/nd_host_artwork_stub.go +++ b/plugins/pdk/go/host/nd_host_artwork_stub.go @@ -8,9 +8,7 @@ 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 faf4e65d2..fbd80d13f 100644 --- a/plugins/pdk/go/host/nd_host_cache_stub.go +++ b/plugins/pdk/go/host/nd_host_cache_stub.go @@ -8,9 +8,7 @@ package host -import ( - "github.com/stretchr/testify/mock" -) +import "github.com/stretchr/testify/mock" // mockCacheService is the mock implementation for testing. type mockCacheService struct { @@ -154,11 +152,7 @@ func CacheSetBytes(key string, value []byte, ttlSeconds int64) error { // GetBytes is the mock method for CacheGetBytes. func (m *mockCacheService) GetBytes(key string) ([]byte, bool, error) { args := m.Called(key) - var r0 []byte - if v := args.Get(0); v != nil { - r0 = v.([]byte) - } - return r0, args.Bool(1), args.Error(2) + return args.Get(0).([]byte), args.Bool(1), args.Error(2) } // CacheGetBytes delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_config_stub.go b/plugins/pdk/go/host/nd_host_config_stub.go index 0327346a3..2b8485ce9 100644 --- a/plugins/pdk/go/host/nd_host_config_stub.go +++ b/plugins/pdk/go/host/nd_host_config_stub.go @@ -8,9 +8,7 @@ package host -import ( - "github.com/stretchr/testify/mock" -) +import "github.com/stretchr/testify/mock" // mockConfigService is the mock implementation for testing. type mockConfigService struct { @@ -59,11 +57,7 @@ func ConfigGetInt(key string) (int64, bool) { // Keys is the mock method for ConfigKeys. func (m *mockConfigService) Keys(prefix string) []string { args := m.Called(prefix) - var r0 []string - if v := args.Get(0); v != nil { - r0 = v.([]string) - } - return r0 + return args.Get(0).([]string) } // ConfigKeys delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_http_stub.go b/plugins/pdk/go/host/nd_host_http_stub.go index 8706ac9e5..2f15a91a9 100644 --- a/plugins/pdk/go/host/nd_host_http_stub.go +++ b/plugins/pdk/go/host/nd_host_http_stub.go @@ -8,9 +8,7 @@ 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. @@ -43,11 +41,7 @@ var HTTPMock = &mockHTTPService{} // Send is the mock method for HTTPSend. func (m *mockHTTPService) Send(request HTTPRequest) (*HTTPResponse, error) { args := m.Called(request) - var r0 *HTTPResponse - if v := args.Get(0); v != nil { - r0 = v.(*HTTPResponse) - } - return r0, args.Error(1) + return args.Get(0).(*HTTPResponse), args.Error(1) } // HTTPSend delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_kvstore_stub.go b/plugins/pdk/go/host/nd_host_kvstore_stub.go index e5243860a..83b55d3a8 100644 --- a/plugins/pdk/go/host/nd_host_kvstore_stub.go +++ b/plugins/pdk/go/host/nd_host_kvstore_stub.go @@ -8,9 +8,7 @@ package host -import ( - "github.com/stretchr/testify/mock" -) +import "github.com/stretchr/testify/mock" // mockKVStoreService is the mock implementation for testing. type mockKVStoreService struct { @@ -64,11 +62,7 @@ func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error { // Get is the mock method for KVStoreGet. func (m *mockKVStoreService) Get(key string) ([]byte, bool, error) { args := m.Called(key) - var r0 []byte - if v := args.Get(0); v != nil { - r0 = v.([]byte) - } - return r0, args.Bool(1), args.Error(2) + return args.Get(0).([]byte), args.Bool(1), args.Error(2) } // KVStoreGet delegates to the mock instance. @@ -85,11 +79,7 @@ func KVStoreGet(key string) ([]byte, bool, error) { // GetMany is the mock method for KVStoreGetMany. func (m *mockKVStoreService) GetMany(keys []string) (map[string][]byte, error) { args := m.Called(keys) - var r0 map[string][]byte - if v := args.Get(0); v != nil { - r0 = v.(map[string][]byte) - } - return r0, args.Error(1) + return args.Get(0).(map[string][]byte), args.Error(1) } // KVStoreGetMany delegates to the mock instance. @@ -124,11 +114,7 @@ func KVStoreHas(key string) (bool, error) { // List is the mock method for KVStoreList. func (m *mockKVStoreService) List(prefix string) ([]string, error) { args := m.Called(prefix) - var r0 []string - if v := args.Get(0); v != nil { - r0 = v.([]string) - } - return r0, args.Error(1) + return args.Get(0).([]string), args.Error(1) } // KVStoreList delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_library_stub.go b/plugins/pdk/go/host/nd_host_library_stub.go index 0232c49d9..9ad0d97e7 100644 --- a/plugins/pdk/go/host/nd_host_library_stub.go +++ b/plugins/pdk/go/host/nd_host_library_stub.go @@ -8,9 +8,7 @@ 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. @@ -39,11 +37,7 @@ var LibraryMock = &mockLibraryService{} // GetLibrary is the mock method for LibraryGetLibrary. func (m *mockLibraryService) GetLibrary(id int32) (*Library, error) { args := m.Called(id) - var r0 *Library - if v := args.Get(0); v != nil { - r0 = v.(*Library) - } - return r0, args.Error(1) + return args.Get(0).(*Library), args.Error(1) } // LibraryGetLibrary delegates to the mock instance. @@ -60,11 +54,7 @@ func LibraryGetLibrary(id int32) (*Library, error) { // GetAllLibraries is the mock method for LibraryGetAllLibraries. func (m *mockLibraryService) GetAllLibraries() ([]Library, error) { args := m.Called() - var r0 []Library - if v := args.Get(0); v != nil { - r0 = v.([]Library) - } - return r0, args.Error(1) + return args.Get(0).([]Library), args.Error(1) } // LibraryGetAllLibraries delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_matcher.go b/plugins/pdk/go/host/nd_host_matcher.go deleted file mode 100644 index b32ac6f9d..000000000 --- a/plugins/pdk/go/host/nd_host_matcher.go +++ /dev/null @@ -1,77 +0,0 @@ -// 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 deleted file mode 100644 index ade368c06..000000000 --- a/plugins/pdk/go/host/nd_host_matcher_stub.go +++ /dev/null @@ -1,48 +0,0 @@ -// 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) - var r0 []*types.Track - if v := args.Get(0); v != nil { - r0 = v.([]*types.Track) - } - return r0, 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 ac2a8821f..3eaa0087a 100644 --- a/plugins/pdk/go/host/nd_host_scheduler_stub.go +++ b/plugins/pdk/go/host/nd_host_scheduler_stub.go @@ -8,9 +8,7 @@ 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_scrobbleretriever.go b/plugins/pdk/go/host/nd_host_scrobbleretriever.go deleted file mode 100644 index 596d7ec1d..000000000 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever.go +++ /dev/null @@ -1,266 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the ScrobbleRetriever 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/pdk" -) - -// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. -// ScrobbleCountOptions carries optional parameters for counting user scrobbles -type ScrobbleCountOptions struct { - FromTimestamp *int64 `json:"fromTimestamp"` - ToTimestamp *int64 `json:"toTimestamp"` -} - -// ScrobbleOptions represents the ScrobbleOptions data structure. -// ScrobbleOptions carries optional parameters for retrieving user scrobbles -type ScrobbleOptions struct { - FromTimestamp *int64 `json:"fromTimestamp"` - ToTimestamp *int64 `json:"toTimestamp"` - Descending bool `json:"descending"` - MaxItems int `json:"maxItems"` - Offset int `json:"offset"` -} - -// ScrobbleRef represents the ScrobbleRef data structure. -// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) -type ScrobbleRef struct { - ID int64 `json:"id"` - MediaFileID string `json:"mediaFileId"` - SubmissionTime int64 `json:"submissionTime"` -} - -// scrobbleretriever_getfirsttimestamp is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scrobbleretriever_getfirsttimestamp -func scrobbleretriever_getfirsttimestamp(uint64) uint64 - -// scrobbleretriever_getlasttimestamp is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scrobbleretriever_getlasttimestamp -func scrobbleretriever_getlasttimestamp(uint64) uint64 - -// scrobbleretriever_getscrobbles is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scrobbleretriever_getscrobbles -func scrobbleretriever_getscrobbles(uint64) uint64 - -// scrobbleretriever_getscrobblecount is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scrobbleretriever_getscrobblecount -func scrobbleretriever_getscrobblecount(uint64) uint64 - -type scrobbleRetrieverGetFirstTimestampRequest struct { - Username string `json:"username"` -} - -type scrobbleRetrieverGetFirstTimestampResponse struct { - Result *int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -type scrobbleRetrieverGetLastTimestampRequest struct { - Username string `json:"username"` -} - -type scrobbleRetrieverGetLastTimestampResponse struct { - Result *int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -type scrobbleRetrieverGetScrobblesRequest struct { - Username string `json:"username"` - Options ScrobbleOptions `json:"options"` -} - -type scrobbleRetrieverGetScrobblesResponse struct { - Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"` - Next *ScrobbleOptions `json:"next,omitempty"` - Error string `json:"error,omitempty"` -} - -type scrobbleRetrieverGetScrobbleCountRequest struct { - Username string `json:"username"` - Options ScrobbleCountOptions `json:"options"` -} - -type scrobbleRetrieverGetScrobbleCountResponse struct { - Result int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// ScrobbleRetrieverGetFirstTimestamp calls the scrobbleretriever_getfirsttimestamp host function. -// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. -// If the user has no scrobbles, returns nil -func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { - // Marshal request to JSON - req := scrobbleRetrieverGetFirstTimestampRequest{ - Username: username, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := scrobbleretriever_getfirsttimestamp(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response scrobbleRetrieverGetFirstTimestampResponse - 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.Result, nil -} - -// ScrobbleRetrieverGetLastTimestamp calls the scrobbleretriever_getlasttimestamp host function. -// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user -// If the user has no scrobbles, return nil -func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { - // Marshal request to JSON - req := scrobbleRetrieverGetLastTimestampRequest{ - Username: username, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := scrobbleretriever_getlasttimestamp(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response scrobbleRetrieverGetLastTimestampResponse - 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.Result, nil -} - -// ScrobbleRetrieverGetScrobbles calls the scrobbleretriever_getscrobbles host function. -// GetScrobbles returns one page of scrobbles for a user. -// -// Parameters: -// - username: the user to query for scrobbles -// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest -// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 -// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually -// -// Returns: -// - scrobbles: The scrobbles in the requested range, ordered by submission time -// (ties broken by scrobble ID) in the direction given by options.Descending -// - next: The options for the following page, or nil once no scrobbles remain. -// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an -// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range -func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { - // Marshal request to JSON - req := scrobbleRetrieverGetScrobblesRequest{ - Username: username, - Options: options, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := scrobbleretriever_getscrobbles(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response scrobbleRetrieverGetScrobblesResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, nil, errors.New(response.Error) - } - - return response.Scrobbles, response.Next, nil -} - -// ScrobbleRetrieverGetScrobbleCount calls the scrobbleretriever_getscrobblecount host function. -// GetScrobbleCount returns the number of scrobbles for a user in a given range -// -// Parameters: -// - username: the user to query for scrobbles -// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -// -// Returns: -// - the number of scrobbles in the given range, or 0 -func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { - // Marshal request to JSON - req := scrobbleRetrieverGetScrobbleCountRequest{ - Username: username, - Options: options, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return 0, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := scrobbleretriever_getscrobblecount(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response scrobbleRetrieverGetScrobbleCountResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return 0, err - } - - // Convert Error field to Go error - if response.Error != "" { - return 0, errors.New(response.Error) - } - - return response.Result, nil -} diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go deleted file mode 100644 index a75fcebb5..000000000 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go +++ /dev/null @@ -1,136 +0,0 @@ -// 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/stretchr/testify/mock" -) - -// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. -// ScrobbleCountOptions carries optional parameters for counting user scrobbles -type ScrobbleCountOptions struct { - FromTimestamp *int64 `json:"fromTimestamp"` - ToTimestamp *int64 `json:"toTimestamp"` -} - -// ScrobbleOptions represents the ScrobbleOptions data structure. -// ScrobbleOptions carries optional parameters for retrieving user scrobbles -type ScrobbleOptions struct { - FromTimestamp *int64 `json:"fromTimestamp"` - ToTimestamp *int64 `json:"toTimestamp"` - Descending bool `json:"descending"` - MaxItems int `json:"maxItems"` - Offset int `json:"offset"` -} - -// ScrobbleRef represents the ScrobbleRef data structure. -// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) -type ScrobbleRef struct { - ID int64 `json:"id"` - MediaFileID string `json:"mediaFileId"` - SubmissionTime int64 `json:"submissionTime"` -} - -// mockScrobbleRetrieverService is the mock implementation for testing. -type mockScrobbleRetrieverService struct { - mock.Mock -} - -// ScrobbleRetrieverMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.ScrobbleRetrieverMock.On("MethodName", args...).Return(values...) -var ScrobbleRetrieverMock = &mockScrobbleRetrieverService{} - -// GetFirstTimestamp is the mock method for ScrobbleRetrieverGetFirstTimestamp. -func (m *mockScrobbleRetrieverService) GetFirstTimestamp(username string) (*int64, error) { - args := m.Called(username) - var r0 *int64 - if v := args.Get(0); v != nil { - r0 = v.(*int64) - } - return r0, args.Error(1) -} - -// ScrobbleRetrieverGetFirstTimestamp delegates to the mock instance. -// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. -// If the user has no scrobbles, returns nil -func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { - return ScrobbleRetrieverMock.GetFirstTimestamp(username) -} - -// GetLastTimestamp is the mock method for ScrobbleRetrieverGetLastTimestamp. -func (m *mockScrobbleRetrieverService) GetLastTimestamp(username string) (*int64, error) { - args := m.Called(username) - var r0 *int64 - if v := args.Get(0); v != nil { - r0 = v.(*int64) - } - return r0, args.Error(1) -} - -// ScrobbleRetrieverGetLastTimestamp delegates to the mock instance. -// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user -// If the user has no scrobbles, return nil -func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { - return ScrobbleRetrieverMock.GetLastTimestamp(username) -} - -// GetScrobbles is the mock method for ScrobbleRetrieverGetScrobbles. -func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { - args := m.Called(username, options) - var r0 []ScrobbleRef - if v := args.Get(0); v != nil { - r0 = v.([]ScrobbleRef) - } - var r1 *ScrobbleOptions - if v := args.Get(1); v != nil { - r1 = v.(*ScrobbleOptions) - } - return r0, r1, args.Error(2) -} - -// ScrobbleRetrieverGetScrobbles delegates to the mock instance. -// GetScrobbles returns one page of scrobbles for a user. -// -// Parameters: -// - username: the user to query for scrobbles -// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest -// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 -// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually -// -// Returns: -// - scrobbles: The scrobbles in the requested range, ordered by submission time -// (ties broken by scrobble ID) in the direction given by options.Descending -// - next: The options for the following page, or nil once no scrobbles remain. -// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an -// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range -func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { - return ScrobbleRetrieverMock.GetScrobbles(username, options) -} - -// GetScrobbleCount is the mock method for ScrobbleRetrieverGetScrobbleCount. -func (m *mockScrobbleRetrieverService) GetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { - args := m.Called(username, options) - return args.Get(0).(int64), args.Error(1) -} - -// ScrobbleRetrieverGetScrobbleCount delegates to the mock instance. -// GetScrobbleCount returns the number of scrobbles for a user in a given range -// -// Parameters: -// - username: the user to query for scrobbles -// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -// -// Returns: -// - the number of scrobbles in the given range, or 0 -func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { - return ScrobbleRetrieverMock.GetScrobbleCount(username, options) -} diff --git a/plugins/pdk/go/host/nd_host_storage.go b/plugins/pdk/go/host/nd_host_storage.go deleted file mode 100644 index 4f37bc8e2..000000000 --- a/plugins/pdk/go/host/nd_host_storage.go +++ /dev/null @@ -1,46 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Storage host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// storage_getstoragepath is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user storage_getstoragepath -func storage_getstoragepath(uint64) uint64 - -type storageGetStoragePathResponse struct { - Result string `json:"result,omitempty"` -} - -// StorageGetStoragePath calls the storage_getstoragepath host function. -// GetStoragePath retrieves the persistent storage path, if allowed -func StorageGetStoragePath() string { - // No parameters - allocate empty JSON object - reqMem := pdk.AllocateBytes([]byte("{}")) - defer reqMem.Free() - - // Call the host function - responsePtr := storage_getstoragepath(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response storageGetStoragePathResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "" - } - - return response.Result -} diff --git a/plugins/pdk/go/host/nd_host_storage_stub.go b/plugins/pdk/go/host/nd_host_storage_stub.go deleted file mode 100644 index c2213ece9..000000000 --- a/plugins/pdk/go/host/nd_host_storage_stub.go +++ /dev/null @@ -1,34 +0,0 @@ -// 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/stretchr/testify/mock" -) - -// mockStorageService is the mock implementation for testing. -type mockStorageService struct { - mock.Mock -} - -// StorageMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.StorageMock.On("MethodName", args...).Return(values...) -var StorageMock = &mockStorageService{} - -// GetStoragePath is the mock method for StorageGetStoragePath. -func (m *mockStorageService) GetStoragePath() string { - args := m.Called() - return args.String(0) -} - -// StorageGetStoragePath delegates to the mock instance. -// GetStoragePath retrieves the persistent storage path, if allowed -func StorageGetStoragePath() string { - return StorageMock.GetStoragePath() -} diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index ef3d1b735..2fdaf2403 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -8,9 +8,7 @@ package host -import ( - "github.com/stretchr/testify/mock" -) +import "github.com/stretchr/testify/mock" // mockSubsonicAPIService is the mock implementation for testing. type mockSubsonicAPIService struct { @@ -39,11 +37,7 @@ func SubsonicAPICall(uri string) (string, error) { // CallRaw is the mock method for SubsonicAPICallRaw. func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) { args := m.Called(uri) - var r1 []byte - if v := args.Get(1); v != nil { - r1 = v.([]byte) - } - return args.String(0), r1, args.Error(2) + return args.String(0), args.Get(1).([]byte), args.Error(2) } // SubsonicAPICallRaw delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_task_stub.go b/plugins/pdk/go/host/nd_host_task_stub.go index f2c45a2b5..4dde0e234 100644 --- a/plugins/pdk/go/host/nd_host_task_stub.go +++ b/plugins/pdk/go/host/nd_host_task_stub.go @@ -8,9 +8,7 @@ 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. @@ -70,11 +68,7 @@ func TaskEnqueue(queueName string, payload []byte) (string, error) { // Get is the mock method for TaskGet. func (m *mockTaskService) Get(taskID string) (*TaskInfo, error) { args := m.Called(taskID) - var r0 *TaskInfo - if v := args.Get(0); v != nil { - r0 = v.(*TaskInfo) - } - return r0, args.Error(1) + return args.Get(0).(*TaskInfo), args.Error(1) } // TaskGet delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_users_stub.go b/plugins/pdk/go/host/nd_host_users_stub.go index e0f8c0939..f76854894 100644 --- a/plugins/pdk/go/host/nd_host_users_stub.go +++ b/plugins/pdk/go/host/nd_host_users_stub.go @@ -8,9 +8,7 @@ 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. @@ -33,11 +31,7 @@ var UsersMock = &mockUsersService{} // GetUsers is the mock method for UsersGetUsers. func (m *mockUsersService) GetUsers() ([]User, error) { args := m.Called() - var r0 []User - if v := args.Get(0); v != nil { - r0 = v.([]User) - } - return r0, args.Error(1) + return args.Get(0).([]User), args.Error(1) } // UsersGetUsers delegates to the mock instance. @@ -53,11 +47,7 @@ func UsersGetUsers() ([]User, error) { // GetAdmins is the mock method for UsersGetAdmins. func (m *mockUsersService) GetAdmins() ([]User, error) { args := m.Called() - var r0 []User - if v := args.Get(0); v != nil { - r0 = v.([]User) - } - return r0, args.Error(1) + return args.Get(0).([]User), args.Error(1) } // UsersGetAdmins delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_websocket_stub.go b/plugins/pdk/go/host/nd_host_websocket_stub.go index ca6e39c44..23ac382f0 100644 --- a/plugins/pdk/go/host/nd_host_websocket_stub.go +++ b/plugins/pdk/go/host/nd_host_websocket_stub.go @@ -8,9 +8,7 @@ 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 b3c7f29ac..6696bc4a1 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -9,11 +9,17 @@ package lyrics 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 +// 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"` +} // GetLyricsRequest contains the track information for lyrics lookup. type GetLyricsRequest struct { @@ -45,9 +51,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 []types.ArtistRef `json:"artists"` + Artists []ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []types.ArtistRef `json:"albumArtists"` + AlbumArtists []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 76c54d2e5..fb3e3fb1a 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -8,10 +8,15 @@ package lyrics -import "github.com/navidrome/navidrome/plugins/pdk/go/types" - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef +// 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"` +} // GetLyricsRequest contains the track information for lyrics lookup. type GetLyricsRequest struct { @@ -43,9 +48,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 []types.ArtistRef `json:"artists"` + Artists []ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []types.ArtistRef `json:"albumArtists"` + AlbumArtists []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 57546352e..7cd63865b 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -9,27 +9,8 @@ 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 - -// MetadataAgentError represents an error type for metadata agent operations. -type MetadataAgentError string - -const ( - // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. - // Append ":" inside the parentheses to request a specific delay. - MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" -) - -// Error implements the error interface for MetadataAgentError. -func (e MetadataAgentError) Error() string { return string(e) } - // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -84,6 +65,16 @@ 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. @@ -123,7 +114,7 @@ type SimilarArtistsRequest struct { // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []types.ArtistRef `json:"artists"` + Artists []ArtistRef `json:"artists"` } // SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. @@ -169,7 +160,29 @@ type SimilarSongsByTrackRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []types.SongRef `json:"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"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -187,7 +200,7 @@ type TopSongsRequest struct { // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []types.SongRef `json:"songs"` + Songs []SongRef `json:"songs"` } // Metadata is the marker interface for metadata plugins. @@ -198,9 +211,6 @@ type TopSongsResponse struct { // // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. -// -// To say "no data for this item", return a nil response and a nil error. Return an error only when -// the plugin itself failed, because Navidrome retries failed calls with backoff. type Metadata interface{} // ArtistMBIDProvider provides the GetArtistMBID function. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index f979419a9..bdcd06fcb 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -8,26 +8,6 @@ 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 - -// MetadataAgentError represents an error type for metadata agent operations. -type MetadataAgentError string - -const ( - // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. - // Append ":" inside the parentheses to request a specific delay. - MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" -) - -// Error implements the error interface for MetadataAgentError. -func (e MetadataAgentError) Error() string { return string(e) } - // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. @@ -82,6 +62,16 @@ 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. @@ -121,7 +111,7 @@ type SimilarArtistsRequest struct { // SimilarArtistsResponse is the response for GetSimilarArtists. type SimilarArtistsResponse struct { // Artists is the list of similar artists. - Artists []types.ArtistRef `json:"artists"` + Artists []ArtistRef `json:"artists"` } // SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. @@ -167,7 +157,29 @@ type SimilarSongsByTrackRequest struct { // SimilarSongsResponse is the response for GetSimilarSongsBy* functions. type SimilarSongsResponse struct { // Songs is the list of similar songs. - Songs []types.SongRef `json:"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"` } // TopSongsRequest is the request for GetArtistTopSongs. @@ -185,7 +197,7 @@ type TopSongsRequest struct { // TopSongsResponse is the response for GetArtistTopSongs. type TopSongsResponse struct { // Songs is the list of top songs. - Songs []types.SongRef `json:"songs"` + Songs []SongRef `json:"songs"` } // Metadata is the marker interface for metadata plugins. @@ -196,9 +208,6 @@ type TopSongsResponse struct { // // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. -// -// To say "no data for this item", return a nil response and a nil error. Return an error only when -// the plugin itself failed, because Navidrome retries failed calls with backoff. type Metadata interface{} // ArtistMBIDProvider provides the GetArtistMBID function. diff --git a/plugins/pdk/go/pdk/example_test.go b/plugins/pdk/go/pdk/example_test.go index bf98eba2f..5678bddd4 100644 --- a/plugins/pdk/go/pdk/example_test.go +++ b/plugins/pdk/go/pdk/example_test.go @@ -8,7 +8,6 @@ package pdk_test import ( "testing" - "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" "github.com/stretchr/testify/mock" ) @@ -137,39 +136,48 @@ func TestProcessJSONRequest(t *testing.T) { } // ============================================================================= -// HTTP requests go through the host HTTP service (host.HTTPSend), not -// pdk.NewHTTPRequest, which Navidrome does not enable. +// Examples using stub types (Memory, HTTPRequest, HTTPResponse) // ============================================================================= // FetchData demonstrates a plugin function that makes an HTTP request. func FetchData(url string) ([]byte, error) { - resp, err := host.HTTPSend(host.HTTPRequest{ - Method: "GET", - URL: url, - Headers: map[string]string{ - "Accept": "application/json", - "User-Agent": "MyPlugin/1.0", - }, - }) - if err != nil { - return nil, err - } + // Create and configure the HTTP request + // Note: SetHeader and SetBody work directly on the stub - no mocking needed! + req := pdk.NewHTTPRequest(pdk.MethodGet, url) + req.SetHeader("Accept", "application/json") + req.SetHeader("User-Agent", "MyPlugin/1.0") - if resp.StatusCode != 200 { + // Send the request - this is mocked because it requires host interaction + resp := req.Send() + + // Check status - works directly on the stub + if resp.Status() != 200 { return nil, nil } - return resp.Body, nil + + // Return body - works directly on the stub + return resp.Body(), nil } func TestFetchData(t *testing.T) { - host.HTTPMock.ExpectedCalls = nil + pdk.ResetMock() + // Create a stub response with test data expectedBody := []byte(`{"result": "success"}`) - host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { - return req.Method == "GET" && req.URL == "https://api.example.com/data" && - req.Headers["Accept"] == "application/json" - })).Return(&host.HTTPResponse{StatusCode: 200, Body: expectedBody}, nil) + stubResponse := pdk.NewStubHTTPResponse(200, map[string]string{ + "Content-Type": "application/json", + }, expectedBody) + // Mock NewHTTPRequest to return a real HTTPRequest struct + // The struct methods (SetHeader, SetBody) work without mocking + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://api.example.com/data"). + Return(&pdk.HTTPRequest{}) + + // Mock Send to return our stub response + pdk.PDKMock.On("Send", mock.AnythingOfType("*pdk.HTTPRequest")). + Return(stubResponse) + + // Call the function body, err := FetchData("https://api.example.com/data") if err != nil { @@ -180,15 +188,21 @@ func TestFetchData(t *testing.T) { t.Errorf("expected body %q, got %q", expectedBody, body) } - host.HTTPMock.AssertExpectations(t) + pdk.PDKMock.AssertExpectations(t) } func TestFetchData_NonOKStatus(t *testing.T) { - host.HTTPMock.ExpectedCalls = nil + pdk.ResetMock() - host.HTTPMock.On("Send", mock.Anything). - Return(&host.HTTPResponse{StatusCode: 404, Body: []byte("Not Found")}, nil) + // Create a stub response with 404 status + stubResponse := pdk.NewStubHTTPResponse(404, nil, []byte("Not Found")) + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://api.example.com/missing"). + Return(&pdk.HTTPRequest{}) + pdk.PDKMock.On("Send", mock.AnythingOfType("*pdk.HTTPRequest")). + Return(stubResponse) + + // Call the function body, err := FetchData("https://api.example.com/missing") if err != nil { @@ -200,7 +214,7 @@ func TestFetchData_NonOKStatus(t *testing.T) { t.Errorf("expected nil body for 404, got %q", body) } - host.HTTPMock.AssertExpectations(t) + pdk.PDKMock.AssertExpectations(t) } // ProcessMemoryData demonstrates working with Memory type. @@ -278,27 +292,23 @@ func TestHTTPMethodString(t *testing.T) { // PostJSON demonstrates a more complex HTTP request with body. func PostJSON(url string, data []byte) (int, error) { - resp, err := host.HTTPSend(host.HTTPRequest{ - Method: "POST", - URL: url, - Headers: map[string]string{"Content-Type": "application/json"}, - Body: data, - }) - if err != nil { - return 0, err - } - return int(resp.StatusCode), nil + req := pdk.NewHTTPRequest(pdk.MethodPost, url) + req.SetHeader("Content-Type", "application/json") + req.SetBody(data) // Works directly on stub + + resp := req.Send() // This is mocked + return int(resp.Status()), nil } func TestPostJSON(t *testing.T) { - host.HTTPMock.ExpectedCalls = nil + pdk.ResetMock() - host.HTTPMock.On("Send", host.HTTPRequest{ - Method: "POST", - URL: "https://api.example.com/items", - Headers: map[string]string{"Content-Type": "application/json"}, - Body: []byte(`{"name":"test"}`), - }).Return(&host.HTTPResponse{StatusCode: 201}, nil) + stubResponse := pdk.NewStubHTTPResponse(201, nil, nil) + + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, "https://api.example.com/items"). + Return(&pdk.HTTPRequest{}) + pdk.PDKMock.On("Send", mock.AnythingOfType("*pdk.HTTPRequest")). + Return(stubResponse) status, err := PostJSON("https://api.example.com/items", []byte(`{"name":"test"}`)) @@ -310,5 +320,5 @@ func TestPostJSON(t *testing.T) { t.Errorf("expected status 201, got %d", status) } - host.HTTPMock.AssertExpectations(t) + pdk.PDKMock.AssertExpectations(t) } diff --git a/plugins/pdk/go/pdk/pdk.go b/plugins/pdk/go/pdk/pdk.go index 456a238f5..35394d700 100644 --- a/plugins/pdk/go/pdk/pdk.go +++ b/plugins/pdk/go/pdk/pdk.go @@ -111,8 +111,6 @@ func LogMemory(level LogLevel, m Memory) { } // NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`. -// -// Deprecated: Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead. func NewHTTPRequest(method HTTPMethod, url string) *HTTPRequest { return extism.NewHTTPRequest(method, url) } diff --git a/plugins/pdk/go/pdk/pdk_stub.go b/plugins/pdk/go/pdk/pdk_stub.go index 1a685eecc..3bdbb1cb7 100644 --- a/plugins/pdk/go/pdk/pdk_stub.go +++ b/plugins/pdk/go/pdk/pdk_stub.go @@ -62,11 +62,7 @@ func GetConfig(key string) (string, bool) { // GetVar GetVar returns the byte slice (if any) associated with `key`. func GetVar(key string) []byte { args := PDKMock.Called(key) - var r0 []byte - if v := args.Get(0); v != nil { - r0 = v.([]byte) - } - return r0 + return args.Get(0).([]byte) } // GetVarInt GetVarInt returns the int associated with `key` (or 0 if none). @@ -78,11 +74,7 @@ func GetVarInt(key string) int { // Input Input returns a slice of bytes from the host. func Input() []byte { args := PDKMock.Called() - var r0 []byte - if v := args.Get(0); v != nil { - r0 = v.([]byte) - } - return r0 + return args.Get(0).([]byte) } // InputJSON InputJSON returns unmartialed JSON data from the host "input". @@ -114,15 +106,9 @@ func LogMemory(level LogLevel, m Memory) { } // NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`. -// -// Deprecated: Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead. func NewHTTPRequest(method HTTPMethod, url string) *HTTPRequest { args := PDKMock.Called(method, url) - var r0 *HTTPRequest - if v := args.Get(0); v != nil { - r0 = v.(*HTTPRequest) - } - return r0 + return args.Get(0).(*HTTPRequest) } func NewMemory(offset uint64, length uint64) Memory { args := PDKMock.Called(offset, length) @@ -153,11 +139,7 @@ func OutputString(s string) { // ParamBytes ParamBytes returns bytes from Extism host memory given an offset. func ParamBytes(offset uint64) []byte { args := PDKMock.Called(offset) - var r0 []byte - if v := args.Get(0); v != nil { - r0 = v.([]byte) - } - return r0 + return args.Get(0).([]byte) } // ParamString ParamString returns UTF-8 string data from Extism host memory given an offset. diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index 22ae4c3bf..0d045e597 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -9,12 +9,8 @@ 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 @@ -30,6 +26,16 @@ 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. @@ -89,9 +95,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 []types.ArtistRef `json:"artists"` + Artists []ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []types.ArtistRef `json:"albumArtists"` + AlbumArtists []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 722ec5462..b35e7c40e 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -8,11 +8,6 @@ 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 @@ -28,6 +23,16 @@ 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. @@ -87,9 +92,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 []types.ArtistRef `json:"artists"` + Artists []ArtistRef `json:"artists"` // AlbumArtists is the list of album artists. - AlbumArtists []types.ArtistRef `json:"albumArtists"` + AlbumArtists []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 fca8bd2c1..7a2681f93 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go @@ -9,32 +9,47 @@ 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 types.SongRef `json:"startSong"` - EndSong types.SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` } // GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. type GetSonicSimilarTracksRequest struct { - Song types.SongRef `json:"song"` - Count int32 `json:"count"` + 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"` } // SonicMatch represents the SonicMatch data structure. type SonicMatch struct { - Song types.SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song 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 518a166e5..28d86301c 100644 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go @@ -8,31 +8,45 @@ 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 types.SongRef `json:"startSong"` - EndSong types.SongRef `json:"endSong"` - Count int32 `json:"count"` + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` } // GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. type GetSonicSimilarTracksRequest struct { - Song types.SongRef `json:"song"` - Count int32 `json:"count"` + 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"` } // SonicMatch represents the SonicMatch data structure. type SonicMatch struct { - Song types.SongRef `json:"song"` - Similarity float64 `json:"similarity"` + Song 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 deleted file mode 100644 index 4d4cdedd2..000000000 --- a/plugins/pdk/go/types/types.go +++ /dev/null @@ -1,148 +0,0 @@ -// 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 new file mode 100644 index 000000000..9bcb529ae --- /dev/null +++ b/plugins/pdk/python/host/nd_host_artwork.py @@ -0,0 +1,183 @@ +# 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 new file mode 100644 index 000000000..b24e983cc --- /dev/null +++ b/plugins/pdk/python/host/nd_host_cache.py @@ -0,0 +1,448 @@ +# 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 new file mode 100644 index 000000000..1dab2fe0e --- /dev/null +++ b/plugins/pdk/python/host/nd_host_config.py @@ -0,0 +1,145 @@ +# 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 new file mode 100644 index 000000000..a806c8456 --- /dev/null +++ b/plugins/pdk/python/host/nd_host_http.py @@ -0,0 +1,60 @@ +# 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 new file mode 100644 index 000000000..c6bfb77c0 --- /dev/null +++ b/plugins/pdk/python/host/nd_host_httpclient.py @@ -0,0 +1,59 @@ +# 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 new file mode 100644 index 000000000..33eaffc52 --- /dev/null +++ b/plugins/pdk/python/host/nd_host_kvstore.py @@ -0,0 +1,362 @@ +# 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 new file mode 100644 index 000000000..12e1bc4eb --- /dev/null +++ b/plugins/pdk/python/host/nd_host_library.py @@ -0,0 +1,86 @@ +# 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 new file mode 100644 index 000000000..7f0d19241 --- /dev/null +++ b/plugins/pdk/python/host/nd_host_scheduler.py @@ -0,0 +1,143 @@ +# 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 new file mode 100644 index 000000000..cf35bc043 --- /dev/null +++ b/plugins/pdk/python/host/nd_host_subsonicapi.py @@ -0,0 +1,101 @@ +# 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 new file mode 100644 index 000000000..5d6e7474c --- /dev/null +++ b/plugins/pdk/python/host/nd_host_task.py @@ -0,0 +1,188 @@ +# 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 new file mode 100644 index 000000000..a325156a7 --- /dev/null +++ b/plugins/pdk/python/host/nd_host_users.py @@ -0,0 +1,80 @@ +# 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 new file mode 100644 index 000000000..4e882914c --- /dev/null +++ b/plugins/pdk/python/host/nd_host_websocket.py @@ -0,0 +1,182 @@ +# 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 e9fe367da..443f19da5 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml @@ -11,7 +11,6 @@ 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 d3d2b19aa..b0361a3a3 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -5,8 +5,6 @@ //! 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 a8449a5ec..0a861a1ed 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -18,9 +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; +/// 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, +} /// GetLyricsRequest contains the track information for lyrics lookup. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -66,10 +77,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 890e16954..463e52c37 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -18,17 +18,6 @@ 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; -/// MetadataAgentError represents an error type for metadata agent operations. -pub type MetadataAgentError = &'static str; -/// MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. -/// Append ":" inside the parentheses to request a specific delay. -pub const METADATA_AGENT_ERROR_RETRY_LATER: MetadataAgentError = "agent(retry_later)"; /// AlbumImagesResponse is the response for GetAlbumImages. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -103,6 +92,20 @@ 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")] @@ -159,7 +162,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)] @@ -224,7 +227,39 @@ pub struct SimilarSongsByTrackRequest { pub struct SimilarSongsResponse { /// Songs is the list of similar songs. #[serde(default)] - pub songs: Vec, + 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, } /// TopSongsRequest is the request for GetArtistTopSongs. #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -249,7 +284,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 b0b843e6c..1e9c51375 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -18,9 +18,6 @@ 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. @@ -29,6 +26,20 @@ 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")] @@ -115,10 +126,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 a28df9c0a..eb2868929 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs @@ -18,20 +18,14 @@ 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: nd_pdk_types::SongRef, + pub start_song: SongRef, #[serde(default)] - pub end_song: nd_pdk_types::SongRef, + pub end_song: SongRef, #[serde(default)] pub count: i32, } @@ -40,16 +34,48 @@ pub struct FindSonicPathRequest { #[serde(rename_all = "camelCase")] pub struct GetSonicSimilarTracksRequest { #[serde(default)] - pub song: nd_pdk_types::SongRef, + pub song: 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: nd_pdk_types::SongRef, + pub song: 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 100ce6a35..519096110 100644 --- a/plugins/pdk/rust/nd-pdk-host/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk-host/Cargo.toml @@ -13,6 +13,5 @@ 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/README.md b/plugins/pdk/rust/nd-pdk-host/README.md index 00bcdfc2f..f722b2e5a 100644 --- a/plugins/pdk/rust/nd-pdk-host/README.md +++ b/plugins/pdk/rust/nd-pdk-host/README.md @@ -84,4 +84,4 @@ Rust plugins must be compiled to WebAssembly: cargo build --target wasm32-wasip1 --release ``` -See the [webhook-rs](../../../examples/webhook-rs/) example for a complete plugin implementation. +See the [webhook-rs](../../examples/webhook-rs/) example for a complete plugin implementation. diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 33cbe7725..3a31bc489 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -38,10 +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. -//! - [`scrobbleretriever`] - allows a plugin to retrieve scrobbles for one or more authorized users. -//! - [`storage`] - provides access to a plugin-specific directory with read/write permissions //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. //! - [`task`] - provides persistent task queues for plugins. //! - [`users`] - provides access to user information for plugins. @@ -89,13 +86,6 @@ 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. @@ -103,20 +93,6 @@ pub mod scheduler { pub use super::nd_host_scheduler::*; } -#[doc(hidden)] -mod nd_host_scrobbleretriever; -/// allows a plugin to retrieve scrobbles for one or more authorized users. -pub mod scrobbleretriever { - pub use super::nd_host_scrobbleretriever::*; -} - -#[doc(hidden)] -mod nd_host_storage; -/// provides access to a plugin-specific directory with read/write permissions -pub mod storage { - pub use super::nd_host_storage::*; -} - #[doc(hidden)] mod nd_host_subsonicapi; /// provides access to Navidrome's Subsonic API from plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs index f1607112c..1c44cd2f3 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs @@ -30,7 +30,7 @@ mod base64_bytes { } /// HTTPRequest represents an outbound HTTP request from a plugin. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HTTPRequest { pub method: String, @@ -47,7 +47,7 @@ pub struct HTTPRequest { } /// HTTPResponse represents the response from an outbound HTTP request. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct HTTPResponse { pub status_code: i32, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs index 966c1607f..b4b9b3fb0 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs @@ -7,7 +7,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; /// Library represents a music library with metadata. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Library { pub id: i32, 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 deleted file mode 100644 index 7fdf88b7f..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs +++ /dev/null @@ -1,65 +0,0 @@ -// 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, Default, 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-host/src/nd_host_scrobbleretriever.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs deleted file mode 100644 index 19010924a..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs +++ /dev/null @@ -1,237 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the ScrobbleRetriever host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -/// ScrobbleCountOptions carries optional parameters for counting user scrobbles -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ScrobbleCountOptions { - #[serde(default)] - pub from_timestamp: Option, - #[serde(default)] - pub to_timestamp: Option, -} - -/// ScrobbleOptions carries optional parameters for retrieving user scrobbles -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ScrobbleOptions { - #[serde(default)] - pub from_timestamp: Option, - #[serde(default)] - pub to_timestamp: Option, - pub descending: bool, - pub max_items: i32, - #[serde(default)] - pub offset: i32, -} - -/// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ScrobbleRef { - pub id: i64, - pub media_file_id: String, - pub submission_time: i64, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetFirstTimestampRequest { - username: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetFirstTimestampResponse { - #[serde(default)] - result: Option, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetLastTimestampRequest { - username: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetLastTimestampResponse { - #[serde(default)] - result: Option, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetScrobblesRequest { - username: String, - options: ScrobbleOptions, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetScrobblesResponse { - #[serde(default)] - scrobbles: Vec, - #[serde(default)] - next: Option, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetScrobbleCountRequest { - username: String, - options: ScrobbleCountOptions, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetScrobbleCountResponse { - #[serde(default)] - result: i64, - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn scrobbleretriever_getfirsttimestamp(input: Json) -> Json; - fn scrobbleretriever_getlasttimestamp(input: Json) -> Json; - fn scrobbleretriever_getscrobbles(input: Json) -> Json; - fn scrobbleretriever_getscrobblecount(input: Json) -> Json; -} - -/// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. -/// If the user has no scrobbles, returns nil -/// -/// # Arguments -/// * `username` - String parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_first_timestamp(username: &str) -> Result, Error> { - let response = unsafe { - scrobbleretriever_getfirsttimestamp(Json(ScrobbleRetrieverGetFirstTimestampRequest { - username: username.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user -/// If the user has no scrobbles, return nil -/// -/// # Arguments -/// * `username` - String parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_last_timestamp(username: &str) -> Result, Error> { - let response = unsafe { - scrobbleretriever_getlasttimestamp(Json(ScrobbleRetrieverGetLastTimestampRequest { - username: username.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// GetScrobbles returns one page of scrobbles for a user. -/// -/// Parameters: -/// - username: the user to query for scrobbles -/// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -/// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -/// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest -/// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 -/// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually -/// -/// Returns: -/// - scrobbles: The scrobbles in the requested range, ordered by submission time -/// (ties broken by scrobble ID) in the direction given by options.Descending -/// - next: The options for the following page, or nil once no scrobbles remain. -/// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an -/// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range -/// -/// # Arguments -/// * `username` - String parameter. -/// * `options` - ScrobbleOptions parameter. -/// -/// # Returns -/// A tuple of (scrobbles, next). -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result<(Vec, Option), Error> { - let response = unsafe { - scrobbleretriever_getscrobbles(Json(ScrobbleRetrieverGetScrobblesRequest { - username: username.to_owned(), - options: options, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok((response.0.scrobbles, response.0.next)) -} - -/// GetScrobbleCount returns the number of scrobbles for a user in a given range -/// -/// Parameters: -/// - username: the user to query for scrobbles -/// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -/// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -/// -/// Returns: -/// - the number of scrobbles in the given range, or 0 -/// -/// # Arguments -/// * `username` - String parameter. -/// * `options` - ScrobbleCountOptions parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_scrobble_count(username: &str, options: ScrobbleCountOptions) -> Result { - let response = unsafe { - scrobbleretriever_getscrobblecount(Json(ScrobbleRetrieverGetScrobbleCountRequest { - username: username.to_owned(), - options: options, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs deleted file mode 100644 index 336ae565f..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Storage host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct StorageGetStoragePathResponse { - #[serde(default)] - result: String, -} - -#[host_fn] -extern "ExtismHost" { - fn storage_getstoragepath(input: Json) -> Json; -} - -/// GetStoragePath retrieves the persistent storage path, if allowed -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_storage_path() -> Result { - let response = unsafe { - storage_getstoragepath(Json(serde_json::json!({})))? - }; - - Ok(response.0.result) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs index ac82fdd08..4f43e165c 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs @@ -30,7 +30,7 @@ mod base64_bytes { } /// QueueConfig holds configuration for a task queue. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QueueConfig { pub concurrency: i32, @@ -41,7 +41,7 @@ pub struct QueueConfig { } /// TaskInfo holds the current state of a task. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TaskInfo { pub status: String, diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs index b31b39fb4..faa795bb9 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; /// User represents a Navidrome user with minimal information exposed to plugins. /// Sensitive fields like password, email, and internal IDs are intentionally excluded. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct User { pub user_name: String, diff --git a/plugins/pdk/rust/nd-pdk-types/Cargo.toml b/plugins/pdk/rust/nd-pdk-types/Cargo.toml deleted file mode 100644 index e20e07021..000000000 --- a/plugins/pdk/rust/nd-pdk-types/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[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 deleted file mode 100644 index fa1a87048..000000000 --- a/plugins/pdk/rust/nd-pdk-types/src/lib.rs +++ /dev/null @@ -1,239 +0,0 @@ -// 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 8ca457914..34fe9f032 100644 --- a/plugins/pdk/rust/nd-pdk/Cargo.toml +++ b/plugins/pdk/rust/nd-pdk/Cargo.toml @@ -13,7 +13,6 @@ 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 36b2ec316..b1389938b 100644 --- a/plugins/pdk/rust/nd-pdk/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk/src/lib.rs @@ -31,8 +31,5 @@ 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 aa07eae32..1799ba3ce 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -7,10 +9,11 @@ import ( "encoding/json" "net/http" "os" + "os/exec" "path/filepath" "runtime" - "strings" "testing" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -21,10 +24,7 @@ import ( . "github.com/onsi/gomega" ) -const ( - testDataDir = "plugins/testdata" - wazeroCacheDir = ".wazero-cache" -) +const testDataDir = "plugins/testdata" // Shared test state initialized in BeforeSuite var ( @@ -35,21 +35,36 @@ var ( func TestPlugins(t *testing.T) { tests.Init(t, false) + buildTestPlugins(t, testDataDir) - // Set globally so tests using configtest.SetupConfig inherit it. The cache - // persists between runs; entries are content-addressed, so a stale one only misses. - conf.Server.CacheFolder = conf.NewDir(filepath.Join(testDataDir, wazeroCacheDir)) - conf.Server.Plugins.CacheSize = "1GB" // the default evicts the cache mid-run + // Create a shared wazero compilation cache directory. + // All test managers will point CacheFolder here so that WASM compilation + // is done once per binary and then reused from disk cache. + sharedCacheDir, err := os.MkdirTemp("", "plugins-shared-cache-*") + if err != nil { + t.Fatalf("Failed to create shared cache dir: %v", err) + } + t.Cleanup(func() { os.RemoveAll(sharedCacheDir) }) + + // Set CacheFolder globally so all tests (including those using + // configtest.SetupConfig) inherit it without needing to set it manually. + conf.Server.CacheFolder = sharedCacheDir log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "Plugins Suite") } -// stubLocalhostDNS resolves "localhost." without real DNS: the trailing dot never matches /etc/hosts. -func stubLocalhostDNS() { - dialResolver = tests.StubResolver(map[string]string{"localhost.": "127.0.0.1"}) - DeferCleanup(func() { dialResolver = nil }) +func buildTestPlugins(t *testing.T, path string) { + t.Helper() + start := time.Now() + t.Logf("[BeforeSuite] Current working directory: %s", path) + cmd := exec.Command("make", "-C", path) + out, err := cmd.CombinedOutput() + t.Logf("[BeforeSuite] Make output: %s elapsed: %s", string(out), time.Since(start)) + if err != nil { + t.Fatalf("Failed to build test plugins: %v", err) + } } // createTestManager creates a new plugin Manager with the given plugin config. @@ -66,49 +81,52 @@ func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plu return createTestManagerWithPluginsAndMetrics(pluginConfig, noopMetricsRecorder{}, plugins...) } -// installTestPlugins copies the given .ndp packages into dir and returns their -// enabled DB rows, so callers can grant whatever access the test needs. -func installTestPlugins(dir string, plugins ...string) model.Plugins { - var rows model.Plugins - for _, plugin := range plugins { - data, err := os.ReadFile(filepath.Join(testdataDir, plugin)) - Expect(err).ToNot(HaveOccurred()) - destPath := filepath.Join(dir, plugin) - Expect(os.WriteFile(destPath, data, 0600)).To(Succeed()) - - hash := sha256.Sum256(data) - rows = append(rows, model.Plugin{ - ID: strings.TrimSuffix(plugin, PackageExtension), - Path: destPath, - SHA256: hex.EncodeToString(hash[:]), - Enabled: true, - }) - } - return rows -} - // createTestManagerWithPluginsAndMetrics creates a new plugin Manager with the given plugin config, -// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, -// and starts the manager. Returns the manager and temp directory path. +// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager. +// Returns the manager and temp directory path. func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) { // Create temp directory tmpDir, err := os.MkdirTemp("", "plugins-test-*") Expect(err).ToNot(HaveOccurred()) - enabledPlugins := installTestPlugins(tmpDir, plugins...) - for i, p := range enabledPlugins { - enabledPlugins[i].AllUsers = true // Allow all users by default in tests - if pluginConfig[p.ID] != nil { - configBytes, err := json.Marshal(pluginConfig[p.ID]) + // Copy test plugins to temp dir and build plugin list with SHA256 + var enabledPlugins model.Plugins + for _, plugin := range plugins { + srcPath := filepath.Join(testdataDir, plugin) + destPath := filepath.Join(tmpDir, plugin) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Compute SHA256 for the plugin + hash := sha256.Sum256(data) + hashHex := hex.EncodeToString(hash[:]) + pluginName := plugin[:len(plugin)-len(PackageExtension)] // Remove .ndp extension + + // Build config JSON if provided + configJSON := "" + if pluginConfig != nil && pluginConfig[pluginName] != nil { + // Encode config to JSON + configBytes, err := json.Marshal(pluginConfig[pluginName]) Expect(err).ToNot(HaveOccurred()) - enabledPlugins[i].Config = string(configBytes) + configJSON = string(configBytes) } + + enabledPlugins = append(enabledPlugins, model.Plugin{ + ID: pluginName, + Path: destPath, + SHA256: hashHex, + Enabled: true, + Config: configJSON, + AllUsers: true, // Allow all users by default in tests + }) } // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.Folder = tmpDir conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugins @@ -135,10 +153,7 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s return manager, tmpDir } -var _ = SynchronizedBeforeSuite(func() { - // Build once: the testdata Makefile is not safe to run concurrently. - buildTestPlugins(testDataDir) -}, func() { +var _ = BeforeSuite(func() { // Get testdata directory (where test plugin .ndp packages live) _, currentFile, _, ok := runtime.Caller(0) Expect(ok).To(BeTrue()) diff --git a/plugins/plugins_suite_windows_test.go b/plugins/plugins_suite_windows_test.go new file mode 100644 index 000000000..ed43bdcc3 --- /dev/null +++ b/plugins/plugins_suite_windows_test.go @@ -0,0 +1,23 @@ +//go:build windows + +package plugins + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Runs the subset of plugin specs compiled on Windows (files without the +// //go:build !windows tag): capabilities, manager_cache, manager_plugin, +// manifest, package. WASM-runtime-dependent specs live in !windows-tagged +// files and aren't reached here. +func TestPlugins(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Plugins Suite") +} diff --git a/plugins/sandbox_fs.go b/plugins/sandbox_fs.go deleted file mode 100644 index 57bba85af..000000000 --- a/plugins/sandbox_fs.go +++ /dev/null @@ -1,157 +0,0 @@ -package plugins - -import ( - "context" - "io/fs" - "path/filepath" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/tetratelabs/wazero" - experimentalsys "github.com/tetratelabs/wazero/experimental/sys" - "github.com/tetratelabs/wazero/experimental/sysfs" - "github.com/tetratelabs/wazero/sys" -) - -// jailedFS confines a mount to its root: wazero resolves guest paths by bare -// concatenation. Named field, not embedding, so a new sys.FS method can't slip through. -type jailedFS struct { - fs experimentalsys.FS -} - -// escapes reports whether a guest path can resolve outside the mount root. -// WASI only validates "/"-separated paths, missing `..\`, `C:x` and device names. -func escapes(path string) bool { - switch path { - case "", ".", "/": // how wazero opens the mount root itself - return false - } - return !filepath.IsLocal(path) -} - -func (jailedFS) Symlink(string, string) experimentalsys.Errno { - return experimentalsys.EPERM -} - -func (j jailedFS) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) { - if escapes(path) { - return nil, experimentalsys.EPERM - } - return j.fs.OpenFile(path, flag, perm) -} - -func (j jailedFS) Lstat(path string) (sys.Stat_t, experimentalsys.Errno) { - if escapes(path) { - return sys.Stat_t{}, experimentalsys.EPERM - } - return j.fs.Lstat(path) -} - -func (j jailedFS) Stat(path string) (sys.Stat_t, experimentalsys.Errno) { - if escapes(path) { - return sys.Stat_t{}, experimentalsys.EPERM - } - return j.fs.Stat(path) -} - -func (j jailedFS) Mkdir(path string, perm fs.FileMode) experimentalsys.Errno { - if escapes(path) { - return experimentalsys.EPERM - } - return j.fs.Mkdir(path, perm) -} - -func (j jailedFS) Chmod(path string, perm fs.FileMode) experimentalsys.Errno { - if escapes(path) { - return experimentalsys.EPERM - } - return j.fs.Chmod(path, perm) -} - -func (j jailedFS) Rename(from, to string) experimentalsys.Errno { - if escapes(from) || escapes(to) { - return experimentalsys.EPERM - } - return j.fs.Rename(from, to) -} - -func (j jailedFS) Rmdir(path string) experimentalsys.Errno { - if escapes(path) { - return experimentalsys.EPERM - } - return j.fs.Rmdir(path) -} - -func (j jailedFS) Unlink(path string) experimentalsys.Errno { - if escapes(path) { - return experimentalsys.EPERM - } - return j.fs.Unlink(path) -} - -func (j jailedFS) Link(oldPath, newPath string) experimentalsys.Errno { - if escapes(oldPath) || escapes(newPath) { - return experimentalsys.EPERM - } - return j.fs.Link(oldPath, newPath) -} - -func (j jailedFS) Readlink(path string) (string, experimentalsys.Errno) { - if escapes(path) { - return "", experimentalsys.EPERM - } - return j.fs.Readlink(path) -} - -func (j jailedFS) Utimens(path string, atim, mtim int64) experimentalsys.Errno { - if escapes(path) { - return experimentalsys.EPERM - } - return j.fs.Utimens(path, atim, mtim) -} - -// mount is a host directory exposed to a plugin at guestPath. -type mount struct { - hostPath string - guestPath string - readOnly bool -} - -// buildMounts lists the libraries the plugin may reach through the filesystem. -func buildMounts(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) []mount { - allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs)) - for _, id := range allowedLibraryIDs { - allowedLibrarySet[id] = struct{}{} - } - var mounts []mount - for _, lib := range libraries { - _, allowed := allowedLibrarySet[lib.ID] - if allLibraries || allowed { - m := mount{hostPath: lib.Path, guestPath: toPluginMountPoint(int32(lib.ID)), readOnly: !allowWriteAccess} - mounts = append(mounts, m) - log.Trace(ctx, "Added library to plugin mounts", "libraryID", lib.ID, "mountPoint", m.guestPath, "readOnly", m.readOnly, "hostPath", m.hostPath) - } - } - if allowWriteAccess { - log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries) - } else { - log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries) - } - return mounts -} - -// buildFSConfig mounts each host directory jailed to its root. -func buildFSConfig(mounts []mount) wazero.FSConfig { - if len(mounts) == 0 { - return nil - } - cfg := wazero.NewFSConfig() - for _, m := range mounts { - var mounted experimentalsys.FS = jailedFS{fs: sysfs.DirFS(m.hostPath)} - if m.readOnly { - mounted = &sysfs.ReadFS{FS: mounted} - } - cfg = cfg.(sysfs.FSConfig).WithSysFSMount(mounted, m.guestPath) - } - return cfg -} diff --git a/plugins/sandbox_fs_internal_test.go b/plugins/sandbox_fs_internal_test.go deleted file mode 100644 index 73011225d..000000000 --- a/plugins/sandbox_fs_internal_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package plugins - -import ( - "os" - "path/filepath" - "runtime" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - experimentalsys "github.com/tetratelabs/wazero/experimental/sys" - "github.com/tetratelabs/wazero/experimental/sysfs" -) - -var _ = Describe("buildMounts", func() { - var libraries model.Libraries - - BeforeEach(func() { - libraries = model.Libraries{ - {ID: 1, Path: "/music/library1"}, - {ID: 2, Path: "/music/library2"}, - {ID: 3, Path: "/music/library3"}, - } - }) - - It("mounts all libraries read-only by default", func() { - Expect(buildMounts(nil, libraries, nil, true, false)).To(Equal([]mount{ - {hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true}, - {hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: true}, - {hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true}, - })) - }) - - It("mounts only the selected libraries", func() { - Expect(buildMounts(nil, libraries, []int{1, 3}, false, false)).To(Equal([]mount{ - {hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true}, - {hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true}, - })) - }) - - It("mounts writable when write access is granted", func() { - Expect(buildMounts(nil, libraries, []int{2}, false, true)).To(Equal([]mount{ - {hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: false}, - })) - }) - - DescribeTable("mounts nothing", - func(libs model.Libraries, allowedIDs []int, allLibraries bool) { - Expect(buildMounts(nil, libs, allowedIDs, allLibraries, false)).To(BeEmpty()) - }, - Entry("when no library matches", libraries, []int{99}, false), - Entry("when there are no libraries", model.Libraries(nil), []int{1}, false), - Entry("when no library is selected", libraries, nil, false), - ) -}) - -var _ = Describe("jailedFS", func() { - var ( - fs jailedFS - root string - outsideDir string - ) - - BeforeEach(func() { - tmpDir := GinkgoT().TempDir() - root = filepath.Join(tmpDir, "root") - outsideDir = filepath.Join(tmpDir, "outside") - Expect(os.MkdirAll(root, 0755)).To(Succeed()) - Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0600)).To(Succeed()) - - fs = jailedFS{sysfs.DirFS(root)} - }) - - It("denies creating symlinks", func() { - Expect(fs.Symlink("../outside", "link")).To(Equal(experimentalsys.EPERM)) - }) - - It("denies reading through a path that escapes the root", func() { - _, errno := fs.OpenFile("../outside/secret.txt", experimentalsys.O_RDONLY, 0) - Expect(errno).To(Equal(experimentalsys.EPERM)) - }) - - It("denies traversal with the host separator", func() { - _, errno := fs.OpenFile(`..\outside\secret.txt`, experimentalsys.O_RDONLY, 0) - - if runtime.GOOS == "windows" { - Expect(errno).To(Equal(experimentalsys.EPERM)) - } else { - // A backslash is a legal filename character on POSIX, not a separator - Expect(errno).To(Equal(experimentalsys.ENOENT)) - } - }) - - It("denies absolute paths", func() { - _, errno := fs.OpenFile("/etc/passwd", experimentalsys.O_RDONLY, 0) - Expect(errno).To(Equal(experimentalsys.EPERM)) - }) - - It("denies creating a directory outside the root", func() { - Expect(fs.Mkdir("../outside/new-dir", 0755)).To(Equal(experimentalsys.EPERM)) - Expect(filepath.Join(outsideDir, "new-dir")).ToNot(BeADirectory()) - }) - - It("denies escaping through either side of a rename", func() { - Expect(fs.Rename("../outside/secret.txt", "stolen.txt")).To(Equal(experimentalsys.EPERM)) - Expect(fs.Rename("inside.txt", "../outside/leaked.txt")).To(Equal(experimentalsys.EPERM)) - }) - - // Music libraries symlink in folders from elsewhere; following them is intended - It("follows symlinks that already exist in the mount", func() { - if err := os.Symlink(outsideDir, filepath.Join(root, "linked")); err != nil { - Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges - } - - _, errno := fs.OpenFile("linked/secret.txt", experimentalsys.O_RDONLY, 0) - Expect(errno).To(BeZero()) - }) - - It("allows access within the root", func() { - Expect(fs.Mkdir("sub", 0755)).To(BeZero()) - f, errno := fs.OpenFile("sub/file.txt", experimentalsys.O_CREAT|experimentalsys.O_WRONLY, 0600) - Expect(errno).To(BeZero()) - Expect(f.Close()).To(BeZero()) - Expect(filepath.Join(root, "sub", "file.txt")).To(BeAnExistingFile()) - }) -}) diff --git a/plugins/sandbox_fs_test.go b/plugins/sandbox_fs_test.go deleted file mode 100644 index 0dd9d15ab..000000000 --- a/plugins/sandbox_fs_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package plugins - -import ( - "context" - "encoding/json" - "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" -) - -type sandboxInput struct { - Operation string `json:"operation"` - MountPoint string `json:"mount_point,omitempty"` - FilePath string `json:"file_path,omitempty"` - Content string `json:"content,omitempty"` - Target string `json:"target,omitempty"` -} - -type sandboxOutput struct { - FileContent string `json:"file_content,omitempty"` - Error *string `json:"error,omitempty"` -} - -// startSandboxManager loads test-library against libraryDir with the given grant. -func startSandboxManager(tmpDir, libraryDir string, grant func(*model.Plugin)) *Manager { - GinkgoHelper() - installed := installTestPlugins(tmpDir, "test-library"+PackageExtension) - grant(&installed[0]) - - 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 - mockPluginRepo.SetData(installed) - - mockLibraryRepo := &tests.MockLibraryRepo{} - mockLibraryRepo.SetData(model.Libraries{{ID: 1, Name: "Test Library", Path: libraryDir}}) - - manager := &Manager{ - plugins: make(map[string]*plugin), - ds: &tests.MockDataStore{MockedPlugin: mockPluginRepo, MockedLibrary: mockLibraryRepo}, - subsonicRouter: http.NotFoundHandler(), - } - Expect(manager.Start(GinkgoT().Context())).To(Succeed()) - DeferCleanup(func() { _ = manager.Stop() }) - return manager -} - -// callSandbox runs one filesystem operation inside the plugin sandbox. -func callSandbox(manager *Manager, input sandboxInput) sandboxOutput { - GinkgoHelper() - manager.mu.RLock() - p := manager.plugins["test-library"] - manager.mu.RUnlock() - Expect(p).ToNot(BeNil()) - - instance, err := p.instance(context.Background()) - Expect(err).ToNot(HaveOccurred()) - defer instance.Close(context.Background()) - - inputBytes, err := json.Marshal(input) - Expect(err).ToNot(HaveOccurred()) - _, outputBytes, err := instance.Call("nd_test_library", inputBytes) - Expect(err).ToNot(HaveOccurred()) - - var output sandboxOutput - Expect(json.Unmarshal(outputBytes, &output)).To(Succeed()) - return output -} - -var _ = Describe("Plugin filesystem sandbox", Ordered, ContinueOnFailure, func() { - var ( - manager *Manager - libraryDir string - outsideDir string - secretFile string - mountPoint string - ) - - call := func(input sandboxInput) sandboxOutput { - GinkgoHelper() - input.MountPoint = mountPoint - return callSandbox(manager, input) - } - - BeforeAll(func() { - tmpDir := GinkgoT().TempDir() - libraryDir = filepath.Join(tmpDir, "music-library") - Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed()) - - // Sibling of the mount root: anything reached here escaped the sandbox - outsideDir = filepath.Join(tmpDir, "outside") - Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed()) - secretFile = filepath.Join(outsideDir, "secret.txt") - Expect(os.WriteFile(secretFile, []byte("secret"), 0600)).To(Succeed()) - - mountPoint = toPluginMountPoint(1) - manager = startSandboxManager(tmpDir, libraryDir, func(p *model.Plugin) { - p.AllLibraries = true - p.AllowWriteAccess = true - }) - }) - - It("allows writing inside the mount", func() { - out := call(sandboxInput{Operation: "write_file", FilePath: "inside.txt", Content: "hello"}) - - Expect(out.Error).To(BeNil()) - Expect(os.ReadFile(filepath.Join(libraryDir, "inside.txt"))).To(BeEquivalentTo("hello")) - }) - - It("cannot escape the mount by creating a symlink", func() { - linked := call(sandboxInput{Operation: "symlink", FilePath: "escape", Target: "../outside"}) - Expect(linked.Error).ToNot(BeNil()) - _, err := os.Lstat(filepath.Join(libraryDir, "escape")) - Expect(err).To(MatchError(os.ErrNotExist), "the plugin created a symlink out of the mount") - - wrote := call(sandboxInput{Operation: "write_file", FilePath: "escape/via-created-symlink.txt", Content: "escaped"}) - - Expect(wrote.Error).ToNot(BeNil()) - Expect(filepath.Join(outsideDir, "via-created-symlink.txt")).ToNot(BeAnExistingFile()) - }) - - // Accepted residual, pinned so a future tightening can't happen silently - It("still follows a symlink planted in the mount by something else", func() { - if err := os.Symlink(outsideDir, filepath.Join(libraryDir, "planted")); err != nil { - Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges - } - - out := call(sandboxInput{Operation: "write_file", FilePath: "planted/via-symlink.txt", Content: "escaped"}) - - Expect(out.Error).To(BeNil()) - Expect(filepath.Join(outsideDir, "via-symlink.txt")).To(BeAnExistingFile()) - }) - - It("cannot read outside the mount with ..", func() { - out := call(sandboxInput{Operation: "read_file", FilePath: "../outside/secret.txt"}) - - Expect(out.Error).ToNot(BeNil()) - Expect(out.FileContent).ToNot(Equal("secret")) - }) - - It("cannot write outside the mount with ..", func() { - out := call(sandboxInput{Operation: "write_file", FilePath: "../outside/via-dotdot.txt", Content: "escaped"}) - - Expect(out.Error).ToNot(BeNil()) - Expect(filepath.Join(outsideDir, "via-dotdot.txt")).ToNot(BeAnExistingFile()) - }) -}) - -var _ = Describe("Plugin filesystem sandbox without library access", Ordered, func() { - var manager *Manager - var libraryDir string - - BeforeAll(func() { - tmpDir := GinkgoT().TempDir() - libraryDir = filepath.Join(tmpDir, "music-library") - Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libraryDir, "track.txt"), []byte("audio"), 0600)).To(Succeed()) - - manager = startSandboxManager(tmpDir, libraryDir, func(*model.Plugin) {}) - }) - - It("mounts nothing when no library is granted", func() { - manager.mu.RLock() - p := manager.plugins["test-library"] - manager.mu.RUnlock() - Expect(p.fsConfig).To(BeNil()) - - out := callSandbox(manager, sandboxInput{Operation: "read_file", MountPoint: toPluginMountPoint(1), FilePath: "track.txt"}) - Expect(out.Error).ToNot(BeNil()) - }) -}) diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 721f0d3fa..8abdccf07 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -9,7 +9,6 @@ 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. @@ -155,10 +154,10 @@ func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo } // participantsToArtistRefs converts a ParticipantList to a slice of ArtistRef -func participantsToArtistRefs(participants model.ParticipantList) []types.ArtistRef { - refs := make([]types.ArtistRef, len(participants)) +func participantsToArtistRefs(participants model.ParticipantList) []capabilities.ArtistRef { + refs := make([]capabilities.ArtistRef, len(participants)) for i, p := range participants { - refs[i] = types.ArtistRef{ + refs[i] = capabilities.ArtistRef{ ID: p.ID, Name: p.Name, MBID: p.MbzArtistID, @@ -174,12 +173,11 @@ func mapScrobblerError(err error) error { return nil } errMsg := err.Error() - retryLater, isRetryLater := parseRetryLater(scrobblerRetryLaterRe, errMsg) switch { case strings.Contains(errMsg, capabilities.ScrobblerErrorNotAuthorized.Error()): return scrobbler.ErrNotAuthorized - case isRetryLater: - return retryLater + case strings.Contains(errMsg, capabilities.ScrobblerErrorRetryLater.Error()): + return scrobbler.ErrRetryLater case strings.Contains(errMsg, capabilities.ScrobblerErrorUnrecoverable.Error()): return scrobbler.ErrUnrecoverable default: diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 00c9045a4..56a452742 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package plugins import ( @@ -5,7 +7,6 @@ import ( "errors" "time" - "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -361,25 +362,4 @@ var _ = Describe("mapScrobblerError", func() { err := mapScrobblerError(errors.New("some unknown error")) Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) - - DescribeTable("mapScrobblerError retry-later", - func(msg string, wantDelay time.Duration) { - err := mapScrobblerError(errors.New(msg)) - Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) - retry, _ := errors.AsType[*agents.RetryLaterError](err) - d := retry.RetryIn - Expect(d).To(Equal(wantDelay)) - }, - Entry("bare token", "scrobbler(retry_later)", time.Duration(0)), - Entry("with seconds", "scrobbler(retry_later:30)", 30*time.Second), - Entry("capped at 1h", "scrobbler(retry_later:999999)", time.Hour), - // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. - Entry("capped before it can overflow", "scrobbler(retry_later:18446744074)", time.Hour), - Entry("wrapped in context", "plugin xyz: scrobbler(retry_later:5)", 5*time.Second), - ) - - It("still maps unknown errors to unrecoverable", func() { - err := mapScrobblerError(errors.New("scrobbler(retry_later_garbage")) - Expect(errors.Is(err, scrobbler.ErrUnrecoverable)).To(BeTrue()) - }) }) diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go index a4f7d73f8..ff399680e 100644 --- a/plugins/sonic_similarity_adapter.go +++ b/plugins/sonic_similarity_adapter.go @@ -6,7 +6,6 @@ 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" @@ -62,8 +61,8 @@ func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endM return sonicMatchesToSimilarResults(resp.Matches), nil } -func mediaFileToSongRef(mf *model.MediaFile) types.SongRef { - ref := types.SongRef{ +func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { + ref := capabilities.SongRef{ ID: mf.ID, Name: mf.Title, MBID: mf.MbzRecordingID, @@ -71,10 +70,7 @@ func mediaFileToSongRef(mf *model.MediaFile) types.SongRef { ArtistMBID: mf.MbzArtistID, Album: mf.Album, AlbumMBID: mf.MbzAlbumID, - } - 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()}) + Duration: mf.Duration, } 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 e08fc124f..daea04761 100644 --- a/plugins/sonic_similarity_adapter_test.go +++ b/plugins/sonic_similarity_adapter_test.go @@ -1,9 +1,10 @@ +//go:build !windows + 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" ) @@ -41,8 +42,7 @@ 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.Artists).To(HaveLen(1)) - Expect(results[0].Song.Artists[0].Name).To(Equal("The Beatles")) + Expect(results[0].Song.Artist).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,8 +67,7 @@ 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.Artists).To(HaveLen(1)) - Expect(results[0].Song.Artists[0].Name).To(Equal("The Beatles")) + Expect(results[0].Song.Artist).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)) @@ -109,24 +108,3 @@ 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/Makefile b/plugins/testdata/Makefile index de948a2bb..d53f2aaee 100644 --- a/plugins/testdata/Makefile +++ b/plugins/testdata/Makefile @@ -10,26 +10,22 @@ all: $(PLUGINS:%=%.ndp) clean: rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm) - rm -rf .wazero-cache $(PLUGINS:%=%.stage) # PDK source files that trigger rebuild when changed (recursive) PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) # Build the .ndp package (zip containing manifest.json + plugin.wasm) -# Stage under a per-target name: a shared plugin.wasm breaks concurrent builds. %.ndp: %.wasm %/manifest.json @rm -f $@ - @rm -rf $*.stage && mkdir -p $*.stage - @cp $< $*.stage/plugin.wasm - zip -j $@ $*/manifest.json $*.stage/plugin.wasm - @rm -rf $*.stage + @cp $< plugin.wasm + zip -j $@ $*/manifest.json plugin.wasm + @rm -f plugin.wasm @mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp -# Build the wasm binary. -buildvcs=false keeps the bytes stable across commits, so -# the test suite's wazero compilation cache still hits after a rebuild. +# Build the wasm binary %.wasm: %/*.go %/go.mod $(PDK_SOURCES) ifdef TINYGO cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ . else - cd $* && GOOS=wasip1 GOARCH=wasm go build -buildvcs=false -buildmode=c-shared -o ../$@ . + cd $* && GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ../$@ . endif \ No newline at end of file diff --git a/plugins/testdata/test-library/main.go b/plugins/testdata/test-library/main.go index e83c9aec6..9c04c5f9b 100644 --- a/plugins/testdata/test-library/main.go +++ b/plugins/testdata/test-library/main.go @@ -14,12 +14,10 @@ import ( // TestLibraryInput is the input for nd_test_library callback. type TestLibraryInput struct { - Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir", "write_file", "symlink" + Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir" LibraryID int32 `json:"library_id,omitempty"` MountPoint string `json:"mount_point,omitempty"` // For filesystem operations FilePath string `json:"file_path,omitempty"` // For read_file operation (relative to mount point) - Content string `json:"content,omitempty"` // For write_file operation - Target string `json:"target,omitempty"` // For symlink operation } // TestLibraryOutput is the output from nd_test_library callback. @@ -90,28 +88,6 @@ func ndTestLibrary() int32 { pdk.OutputJSON(TestLibraryOutput{DirEntries: names}) return 0 - case "write_file": - // Write a file to the mounted library directory - fullPath := filepath.Join(input.MountPoint, input.FilePath) - if err := os.WriteFile(fullPath, []byte(input.Content), 0600); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestLibraryOutput{}) - return 0 - - case "symlink": - // Create a symlink inside the mounted library directory - fullPath := filepath.Join(input.MountPoint, input.FilePath) - if err := os.Symlink(input.Target, fullPath); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestLibraryOutput{}) - return 0 - default: errStr := "unknown operation: " + input.Operation pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) diff --git a/plugins/testdata/test-lyrics/main.go b/plugins/testdata/test-lyrics/main.go index 2ee2dabbf..0e485ceba 100644 --- a/plugins/testdata/test-lyrics/main.go +++ b/plugins/testdata/test-lyrics/main.go @@ -15,47 +15,12 @@ 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 deleted file mode 100644 index 7abbc93c6..000000000 --- a/plugins/testdata/test-matcher/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-matcher/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 80d650b7b..000000000 --- a/plugins/testdata/test-matcher/main.go +++ /dev/null @@ -1,56 +0,0 @@ -// 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 deleted file mode 100644 index 26be5c577..000000000 --- a/plugins/testdata/test-matcher/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 bb8a092c6..23e933eb3 100644 --- a/plugins/testdata/test-metadata-agent/main.go +++ b/plugins/testdata/test-metadata-agent/main.go @@ -8,7 +8,6 @@ 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() { @@ -68,9 +67,9 @@ func (t *testMetadataAgent) GetSimilarArtists(input metadata.SimilarArtistsReque if limit == 0 { limit = 5 } - artists := make([]types.ArtistRef, 0, limit) + artists := make([]metadata.ArtistRef, 0, limit) for i := range limit { - artists = append(artists, types.ArtistRef{ + artists = append(artists, metadata.ArtistRef{ ID: "similar-artist-id-" + strconv.Itoa(i+1), Name: input.Name + " Similar " + string(rune('A'+i)), MBID: "similar-mbid-" + strconv.Itoa(i+1), @@ -87,9 +86,9 @@ func (t *testMetadataAgent) GetArtistTopSongs(input metadata.TopSongsRequest) (* if count == 0 { count = 5 } - songs := make([]types.SongRef, 0, count) + songs := make([]metadata.SongRef, 0, count) for i := range count { - songs = append(songs, types.SongRef{ + songs = append(songs, metadata.SongRef{ ID: "song-id-" + strconv.Itoa(i+1), Name: input.Name + " Song " + strconv.Itoa(i+1), MBID: "song-mbid-" + strconv.Itoa(i+1), @@ -129,9 +128,9 @@ func (t *testMetadataAgent) GetSimilarSongsByTrack(input metadata.SimilarSongsBy if count == 0 { count = 5 } - songs := make([]types.SongRef, 0, count) + songs := make([]metadata.SongRef, 0, count) for i := range count { - songs = append(songs, types.SongRef{ + songs = append(songs, metadata.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), @@ -151,9 +150,9 @@ func (t *testMetadataAgent) GetSimilarSongsByAlbum(input metadata.SimilarSongsBy if count == 0 { count = 5 } - songs := make([]types.SongRef, 0, count) + songs := make([]metadata.SongRef, 0, count) for i := range count { - songs = append(songs, types.SongRef{ + songs = append(songs, metadata.SongRef{ ID: "album-similar-id-" + strconv.Itoa(i+1), Name: "Album Similar #" + strconv.Itoa(i+1), Artist: input.Artist, @@ -171,9 +170,9 @@ func (t *testMetadataAgent) GetSimilarSongsByArtist(input metadata.SimilarSongsB if count == 0 { count = 5 } - songs := make([]types.SongRef, 0, count) + songs := make([]metadata.SongRef, 0, count) for i := range count { - songs = append(songs, types.SongRef{ + songs = append(songs, metadata.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-scrobble-retriever/go.mod b/plugins/testdata/test-scrobble-retriever/go.mod deleted file mode 100644 index 59486d796..000000000 --- a/plugins/testdata/test-scrobble-retriever/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-scrobble-retriever - -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-scrobble-retriever/go.sum b/plugins/testdata/test-scrobble-retriever/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-scrobble-retriever/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-scrobble-retriever/main.go b/plugins/testdata/test-scrobble-retriever/main.go deleted file mode 100644 index cc824acb9..000000000 --- a/plugins/testdata/test-scrobble-retriever/main.go +++ /dev/null @@ -1,120 +0,0 @@ -package main - -import ( - "strconv" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -func main() { - -} - -type TestScrobbleTimestampOutput struct { - Timestamp *int64 `json:"timestamp"` -} - -//go:wasmexport call_get_first_timestamp -func callGetFirstTimestamp() int32 { - username := pdk.InputString() - - time, err := host.ScrobbleRetrieverGetFirstTimestamp(username) - if err != nil { - pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) - return 1 - } - - pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) - return 0 -} - -//go:wasmexport call_get_last_timestamp -func callGetLastTimestamp() int32 { - username := pdk.InputString() - - time, err := host.ScrobbleRetrieverGetLastTimestamp(username) - if err != nil { - pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) - return 1 - } - - pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) - return 0 -} - -type TestScrobbleOptions struct { - Username string `json:"username"` - FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - ToTimestamp *int64 `json:"toTimestamp,omitempty"` - Descending bool `json:"descending"` - Offset int `json:"offset,omitempty"` - MaxItems int `json:"maxItems"` -} - -// TestScrobblePage mirrors the multi-return shape so tests can assert on both values -type TestScrobblePage struct { - Scrobbles []host.ScrobbleRef `json:"scrobbles"` - Next *host.ScrobbleOptions `json:"next"` -} - -//go:wasmexport call_get_scrobbles -func callGetScrobbles() int32 { - var options TestScrobbleOptions - err := pdk.InputJSON(&options) - - if err != nil { - pdk.SetErrorString("failed to deserialize input " + err.Error()) - return 1 - } - - scrobbles, next, err := host.ScrobbleRetrieverGetScrobbles(options.Username, host.ScrobbleOptions{ - FromTimestamp: options.FromTimestamp, - ToTimestamp: options.ToTimestamp, - MaxItems: options.MaxItems, - Descending: options.Descending, - Offset: options.Offset, - }) - - if err != nil { - pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) - return 1 - } - - if scrobbles == nil { - scrobbles = []host.ScrobbleRef{} - } - - pdk.OutputJSON(TestScrobblePage{Scrobbles: scrobbles, Next: next}) - return 0 -} - -type TestScrobbleCountOptions struct { - Username string `json:"username"` - FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - ToTimestamp *int64 `json:"toTimestamp,omitempty"` -} - -//go:wasmexport call_get_scrobbles_count -func callGetScrobblesCount() int32 { - var options TestScrobbleOptions - err := pdk.InputJSON(&options) - - if err != nil { - pdk.SetErrorString("failed to deserialize input " + err.Error()) - return 1 - } - - count, err := host.ScrobbleRetrieverGetScrobbleCount(options.Username, host.ScrobbleCountOptions{ - FromTimestamp: options.FromTimestamp, - ToTimestamp: options.ToTimestamp, - }) - - if err != nil { - pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) - return 1 - } - - pdk.OutputString(strconv.FormatInt(count, 10)) - return 0 -} diff --git a/plugins/testdata/test-scrobble-retriever/manifest.json b/plugins/testdata/test-scrobble-retriever/manifest.json deleted file mode 100644 index a60203704..000000000 --- a/plugins/testdata/test-scrobble-retriever/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "Test Scrobble Retriever", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test plugin for scrobble retriever integration settings", - "permissions": { - "scrobbleRetriever": { - "reason": "For testing scrobble retriever operations" - }, - "users": { - "reason": "Access user information for scrobble retrieval" - } - } -} diff --git a/plugins/testdata/test-sonic-similarity/main.go b/plugins/testdata/test-sonic-similarity/main.go index 538e9c8f3..e315bff15 100644 --- a/plugins/testdata/test-sonic-similarity/main.go +++ b/plugins/testdata/test-sonic-similarity/main.go @@ -8,7 +8,6 @@ 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() { @@ -36,7 +35,7 @@ func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSon matches := make([]sonicsimilarity.SonicMatch, 0, count) for i := range count { matches = append(matches, sonicsimilarity.SonicMatch{ - Song: types.SongRef{ + Song: sonicsimilarity.SongRef{ ID: "similar-track-" + strconv.Itoa(i+1), Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1), Artist: input.Song.Artist, @@ -58,7 +57,7 @@ func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathR matches := make([]sonicsimilarity.SonicMatch, 0, count) for i := range count { matches = append(matches, sonicsimilarity.SonicMatch{ - Song: types.SongRef{ + Song: sonicsimilarity.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/testdata/test-storage-plugin-2 b/plugins/testdata/test-storage-plugin-2 deleted file mode 120000 index 444bafd87..000000000 --- a/plugins/testdata/test-storage-plugin-2 +++ /dev/null @@ -1 +0,0 @@ -test-storage-plugin \ No newline at end of file diff --git a/plugins/testdata/test-storage-plugin/go.mod b/plugins/testdata/test-storage-plugin/go.mod deleted file mode 100644 index 7b9a2b567..000000000 --- a/plugins/testdata/test-storage-plugin/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-storage-plugin - -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-storage-plugin/go.sum b/plugins/testdata/test-storage-plugin/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-storage-plugin/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-storage-plugin/main.go b/plugins/testdata/test-storage-plugin/main.go deleted file mode 100644 index 17ffc8e6b..000000000 --- a/plugins/testdata/test-storage-plugin/main.go +++ /dev/null @@ -1,50 +0,0 @@ -// Test plugin for Storage host function integration tests. -// Build with: tinygo build -o ../test-storage-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go -package main - -import ( - "os" - "path/filepath" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -//go:wasmexport call_read -func callRead() int32 { - path := filepath.Join(host.StorageGetStoragePath(), pdk.InputString()) - content, err := os.ReadFile(path) - if err != nil { - pdk.SetErrorString("failed to read file: " + err.Error()) - return 1 - } - pdk.Output(content) - return 0 -} - -type WriteInput struct { - Path string `json:"path"` - Contents string `json:"contents"` -} - -//go:wasmexport call_write -func callWrite() int32 { - var config WriteInput - err := pdk.InputJSON(&config) - - if err != nil { - pdk.SetErrorString("failed to parse json: " + err.Error()) - return 1 - } - - path := filepath.Join(host.StorageGetStoragePath(), config.Path) - err = os.WriteFile(path, []byte(config.Contents), 0600) - if err != nil { - pdk.SetErrorString("failed to write file: " + err.Error()) - return 1 - } - - return 0 -} - -func main() {} diff --git a/plugins/testdata/test-storage-plugin/manifest.json b/plugins/testdata/test-storage-plugin/manifest.json deleted file mode 100644 index 98ac24a47..000000000 --- a/plugins/testdata/test-storage-plugin/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Test Storage Plugin", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "Test plugin for storage host function", - "permissions": { - "storage": { - "reason": "persistent storage for testing" - } - } -} diff --git a/plugins/testdata_builder_test.go b/plugins/testdata_builder_test.go deleted file mode 100644 index 746a8af22..000000000 --- a/plugins/testdata_builder_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package plugins - -import ( - "errors" - "fmt" - "io/fs" - "os" - "os/exec" - "path/filepath" - "slices" - "sync" - "time" - - "github.com/navidrome/navidrome/utils" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// buildTestPlugins packages every test plugin under dir, replicating -// `make -C plugins/testdata` without needing make or zip on the PATH. -func buildTestPlugins(dir string) { - start := time.Now() - built, err := buildPackages(dir) - fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s: %v\n", time.Since(start), built) - Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") -} - -func buildPackages(dir string) ([]string, error) { - mods, err := filepath.Glob(filepath.Join(dir, "*", "go.mod")) - if err != nil || len(mods) == 0 { - return nil, err - } - pdkTime, err := newestModTime(filepath.Join(dir, "..", "pdk", "go")) - if err != nil { - return nil, err - } - - built := make([]string, len(mods)) - errs := make([]error, len(mods)) - build := func(i int) { - pluginDir := filepath.Dir(mods[i]) - var rebuilt bool - if rebuilt, errs[i] = buildPackage(pluginDir, pdkTime); rebuilt { - built[i] = filepath.Base(pluginDir) - } - } - - // The first build populates the wasip1 stdlib and PDK objects every plugin - // shares; fanning out before it lands makes each one compile them again. - build(0) - var wg sync.WaitGroup - for i := range mods[1:] { - wg.Go(func() { build(i + 1) }) - } - wg.Wait() - return slices.DeleteFunc(built, func(name string) bool { return name == "" }), errors.Join(errs...) -} - -func buildPackage(dir string, pdkTime time.Time) (bool, error) { - sourceTime, err := newestModTime(dir) - if err != nil { - return false, err - } - pkg := dir + PackageExtension - if info, err := os.Stat(pkg); err == nil && info.ModTime().After(utils.TimeNewest(sourceTime, pdkTime)) { - return false, nil - } - - wasm, err := filepath.Abs(pkg + ".build.wasm") - if err != nil { - return false, err - } - defer os.Remove(wasm) - // -buildvcs=false keeps the bytes stable across commits, so the suite's - // wazero compilation cache still hits after a rebuild. - cmd := exec.Command("go", "build", "-buildvcs=false", "-buildmode=c-shared", "-o", wasm, ".") - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") - if out, err := cmd.CombinedOutput(); err != nil { - return false, fmt.Errorf("building %s: %w\n%s", dir, err, out) - } - - tmp := pkg + ".build.ndp" - defer os.Remove(tmp) - if err := packageFiles(tmp, filepath.Join(dir, manifestFileName), wasm); err != nil { - return false, fmt.Errorf("packaging %s: %w", dir, err) - } - return true, os.Rename(tmp, pkg) -} - -func packageFiles(pkg, manifest, wasm string) error { - m, err := os.Open(manifest) - if err != nil { - return err - } - defer m.Close() - w, err := os.Open(wasm) - if err != nil { - return err - } - defer w.Close() - return writeNdp(pkg, m, w) -} - -func newestModTime(root string) (time.Time, error) { - var newest time.Time - err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - info, err := d.Info() - if err != nil { - return err - } - newest = utils.TimeNewest(newest, info.ModTime()) - return nil - }) - return newest, err -} diff --git a/plugins/types/track.go b/plugins/types/track.go deleted file mode 100644 index 9b63c0f26..000000000 --- a/plugins/types/track.go +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index 71928a924..000000000 --- a/plugins/types/types.go +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index 9c058ec4f..000000000 --- a/plugins/types/types_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index ae6d40a25..000000000 --- a/plugins/types/types_test.go +++ /dev/null @@ -1,50 +0,0 @@ -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/reflex.conf b/reflex.conf index 1cbaa7bb7..47dd775ab 100644 --- a/reflex.conf +++ b/reflex.conf @@ -1 +1 @@ --s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -R "^\.worktrees" -- go run -race -tags netgo,sqlite_fts5 . +-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 . diff --git a/release/build-tags.sh b/release/build-tags.sh deleted file mode 100755 index f719117ff..000000000 --- a/release/build-tags.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/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 e7610f25e..e5035adda 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?run=navidrome) or [Zenith](https://zenith.hosting/host/navidrome?ref=navidrome) for a simple cloud solution. + * Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) 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/linux/postinstall.sh b/release/linux/postinstall.sh index 007114ef5..ed39fb127 100644 --- a/release/linux/postinstall.sh +++ b/release/linux/postinstall.sh @@ -13,16 +13,15 @@ if [ ! -f /etc/navidrome/navidrome.toml ]; then printf "MusicFolder = \"/opt/navidrome/music\"\n" >> /etc/navidrome/navidrome.toml fi -# Older versions created these folders as root when this script ran `navidrome`. They were created empty, -# so no -R. Real dirs only, never following links: navidrome owns /var/lib/navidrome and could plant symlinks. -find /var/lib/navidrome/cache /var/lib/navidrome/artwork /var/lib/navidrome/plugins -maxdepth 0 -type d -user root -exec chown -h navidrome:navidrome {} + 2>/dev/null - postinstall_flag="/var/lib/navidrome/.installed" if [ ! -f "$postinstall_flag" ]; then # The primary reason why this would fail is if the service was already installed AND # someone manually removed the .installed flag. In this case, ignore the error navidrome service install --user navidrome --working-directory /var/lib/navidrome --configfile /etc/navidrome/navidrome.toml || : + # Any `navidrome` command will make a cache. Make sure that this is properly owned by the Navidrome user + # and not by root + chown navidrome:navidrome /var/lib/navidrome/cache touch "$postinstall_flag" else navidrome service stop --configfile /etc/navidrome/navidrome.toml && navidrome service start --configfile /etc/navidrome/navidrome.toml diff --git a/release/verify-binary.sh b/release/verify-binary.sh deleted file mode 100755 index cde775992..000000000 --- a/release/verify-binary.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/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/release/wix/SettingsDlg.wxs b/release/wix/SettingsDlg.wxs index cdc625434..4a83f91da 100644 --- a/release/wix/SettingsDlg.wxs +++ b/release/wix/SettingsDlg.wxs @@ -16,7 +16,7 @@ - + diff --git a/release/wix/msitools.dockerfile b/release/wix/msitools.dockerfile index 90249c1ce..38364eb47 100644 --- a/release/wix/msitools.dockerfile +++ b/release/wix/msitools.dockerfile @@ -1,3 +1,3 @@ -FROM alpine +FROM public.ecr.aws/docker/library/alpine RUN apk update && apk add jq msitools WORKDIR /workspace \ No newline at end of file diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 71a6de6b5..6d94bab9d 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -14,7 +14,6 @@ - @@ -24,65 +23,55 @@ - - - - - - - - - - - - + + + - + - - - - + + - - - + + + - - - - - + + + - - - - - + + + + + + - - - - - + + + + + - - - + + + - - - + + + + + @@ -92,12 +81,7 @@ - - NOT MSI_INSTALLATIONDIRECTORY - - - NOT MSI_INSTALLATIONDIRECTORY Not Installed AND NOT WIX_UPGRADE_DETECTED diff --git a/resources/embed.go b/resources/embed.go index 040bb5d84..0386e6f79 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.String(), "resources")), + Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")), } } diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 400a2364d..c540dee05 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -38,9 +38,7 @@ "missing": "Fehlend", "libraryName": "Bibliothek", "composer": "Komponist", - "disc": "Disc %{discNumber}", - "albumGain": "Album Gain", - "trackGain": "Titel Gain" + "disc": "Disc %{discNumber}" }, "actions": { "addToQueue": "Später abspielen", @@ -93,8 +91,7 @@ "addToPlaylist": "Zu einer Wiedergabeliste hinzufügen", "download": "Herunterladen", "info": "Mehr Informationen", - "share": "Freigabe erstellen", - "refresh": "Metadaten aktualisieren" + "share": "Freigabe erstellen" }, "lists": { "all": "Alle", @@ -156,13 +153,11 @@ "newPassword": "Neues Passwort", "token": "Token", "lastAccessAt": "Letzter Zugriff am", - "libraries": "Bibliotheken", - "scrobbleFilter": "Scrobble-Filter" + "libraries": "Bibliotheken" }, "helperTexts": { "name": "Die Änderung wird erst nach dem nächsten Login gültig", - "libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken", - "scrobbleFilter": "Titel, die diesen Regeln für intelligente Wiedergabelisten entsprechen, werden nicht an Last.fm, ListenBrainz oder Scrobbler-Plugins übermittelt. Es werden dieselbe JSON-Syntax und dasselbe Verhalten wie bei intelligenten Wiedergabelisten verwendet. Beispiel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Ist das Feld leer, wird alles gescrobbelt. Lokale Wiedergabezahlen bleiben davon unberührt." + "libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken" }, "notifications": { "created": "Benutzer erstellt", @@ -176,8 +171,7 @@ "adminAutoLibraries": "Administrator-Benutzer haben automatisch Zugriff auf alle Bibliotheken" }, "validation": { - "librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein", - "invalidScrobbleFilter": "Es müssen gültige Regeln für intelligente Wiedergabelisten sein. Limit, Offset und Aktualisierungsverzögerung werden nicht unterstützt." + "librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein" } }, "player": { @@ -200,9 +194,6 @@ "targetFormat": "Zielformat", "defaultBitRate": "Standardbitrate", "command": "Befehl" - }, - "choices": { - "noDefaultBitRate": "Keine" } }, "playlist": { @@ -217,8 +208,7 @@ "songCount": "Titelanzahl", "comment": "Kommentar", "sync": "Auto-Import", - "path": "Importieren aus", - "starred": "Favorit" + "path": "Importieren aus" }, "actions": { "selectPlaylist": "Wiedergabeliste auswählen:", @@ -398,7 +388,6 @@ "invalidJson": "Konfiguration muss valides JSON sein" }, "messages": { - "idHelp": "Die Plugin-ID, abgeleitet vom Dateinamen. Verwende diese, um in Konfigurationen (z. B. bei Agents) auf dieses Plugin zu verweisen.", "configHelp": "Plugin mit Schlüssel-Werte Paaren konfigurieren. Leer lassen wenn das Plugin keine Konfiguration benötigt.", "clickPermissions": "Berechtigung anklicken für mehr Details", "noConfig": "Keine Konfiguration gesetzt", @@ -573,7 +562,7 @@ "noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar", "delete_user_title": "Benutzer '%{name}' löschen", "delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?", - "notifications_blocked": "Benachrichtigungen für diese Seite sind in den Browsereinstellungen blockiert", + "notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert", "notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen", "lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert", "lastfmLinkFailure": "Last.fm konnte nicht verbunden werden", @@ -608,8 +597,7 @@ "coverUploaded": "Cover aktualisiert", "coverRemoved": "Cover entfernt", "coverUploadError": "Fehler beim Hochladen des Covers", - "coverRemoveError": "Fehler beim Entfernen des Covers", - "metadataRefreshStarted": "Aktualisiert Metadaten im Hintergrund" + "coverRemoveError": "Fehler beim Entfernen des Covers" }, "menu": { "library": "Bibliothek", @@ -644,8 +632,7 @@ "multipleLibraries": "%{selected} von %{total} Bibliotheken", "selectLibraries": "Bibliotheken auswählen", "none": "Keine" - }, - "onlyFavourites": "Nur Favoriten anzeigen" + } }, "player": { "playListsText": "Warteschlange abspielen", @@ -731,4 +718,4 @@ "empty": "Keine Wiedergabe", "minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten" } -} +} \ No newline at end of file diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 37882ea99..3876c2e33 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -38,9 +38,7 @@ "missing": "Απών", "libraryName": "Βιβλιοθήκη", "composer": "Συνθέτης", - "disc": "Δίσκος %{discNumber}", - "albumGain": "Κέρδος άλμπουμ", - "trackGain": "Κέρδος παρακολούθησης" + "disc": "Δίσκος %{discNumber}" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -93,8 +91,7 @@ "addToPlaylist": "Προσθηκη στη λιστα αναπαραγωγης", "download": "Ληψη", "info": "Εμφάνιση Πληροφοριών", - "share": "Μερίδιο", - "refresh": "Ανανέωση μεταδεδομένων" + "share": "Μερίδιο" }, "lists": { "all": "Όλα", @@ -156,13 +153,11 @@ "newPassword": "Νέος Κωδικός Πρόσβασης", "token": "Token", "lastAccessAt": "Τελευταία Πρόσβαση", - "libraries": "Βιβλιοθήκες", - "scrobbleFilter": "Φίλτρο Scrobble" + "libraries": "Βιβλιοθήκες" }, "helperTexts": { "name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση", - "libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη", - "scrobbleFilter": "Τα τραγούδια που αντιστοιχούν σε αυτούς τους κανόνες έξυπνης λίστας αναπαραγωγής δεν αποστέλλονται στα πρόσθετα Last.fm, ListenBrainz ή scrobbler. Χρησιμοποιεί την ίδια σύνταξη και συμπεριφορά JSON με τις έξυπνες λίστες αναπαραγωγής. Παράδειγμα: {\"all\":[{\"lt\":{\"rating\":4}}]}." + "libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη" }, "notifications": { "created": "Ο χρήστης δημιουργήθηκε", @@ -176,8 +171,7 @@ "adminAutoLibraries": "Οι χρήστες διαχειριστές έχουν αυτόματα πρόσβαση σε όλες τις βιβλιοθήκες" }, "validation": { - "librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές", - "invalidScrobbleFilter": "Πρέπει να υπάρχουν έγκυροι κανόνες έξυπνης λίστας αναπαραγωγής. Δεν υποστηρίζονται το όριο, η μετατόπιση και η καθυστέρηση ανανέωσης." + "librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές" } }, "player": { @@ -200,9 +194,6 @@ "targetFormat": "Μορφη Προορισμου", "defaultBitRate": "Προκαθορισμένος Ρυθμός Bit", "command": "Εντολή" - }, - "choices": { - "noDefaultBitRate": "" } }, "playlist": { @@ -217,8 +208,7 @@ "songCount": "Τραγούδια", "comment": "Σχόλιο", "sync": "Αυτόματη εισαγωγή", - "path": "Εισαγωγή από", - "starred": "Ευνοούμενος" + "path": "Εισαγωγή από" }, "actions": { "selectPlaylist": "Επιλέξτε μια λίστα αναπαραγωγής:", @@ -411,8 +401,7 @@ "requiredHosts": "Απαιτούμενοι hosts", "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.", - "allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση.", - "idHelp": "" + "allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση." }, "placeholders": { "configKey": "κλειδί", @@ -608,8 +597,7 @@ "coverUploaded": "Το εξώφυλλο ενημερώθηκε", "coverRemoved": "Το εξώφυλλο αφαιρέθηκε", "coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου", - "coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου", - "metadataRefreshStarted": "Ανανέωση μεταδεδομένων στο παρασκήνιο" + "coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου" }, "menu": { "library": "Βιβλιοθήκη", @@ -644,8 +632,7 @@ "multipleLibraries": "%{selected} από %{total} Βιβλιοθήκες", "selectLibraries": "Επιλέξτε βιβλιοθήκες", "none": "Κανένα" - }, - "onlyFavourites": "Εμφάνιση μόνο αγαπημένων" + } }, "player": { "playListsText": "Ουρά Αναπαραγωγής", diff --git a/resources/i18n/es.json b/resources/i18n/es.json index 555c165d6..a018eda3d 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -35,8 +35,6 @@ "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", @@ -695,7 +693,7 @@ "quickScan": "Escaneo rápido", "fullScan": "Escaneo completo", "serverUptime": "Uptime del servidor", - "serverDown": "DESCONECTADO", + "serverDown": "OFFLINE", "scanType": "Tipo", "status": "Error de escaneo", "elapsedTime": "Tiempo transcurrido", diff --git a/resources/i18n/et.json b/resources/i18n/et.json deleted file mode 100644 index b0131f4ea..000000000 --- a/resources/i18n/et.json +++ /dev/null @@ -1,723 +0,0 @@ -{ - "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 30db91cde..6bfd09d0e 100644 --- a/resources/i18n/eu.json +++ b/resources/i18n/eu.json @@ -2,7 +2,7 @@ "languageName": "Euskara", "resources": { "song": { - "name": "Abestia |||| Abestiak", + "name": "Abestia |||| Abesti", "fields": { "albumArtist": "Albumaren artista", "duration": "Iraupena", @@ -22,8 +22,6 @@ "bitRate": "Bit-tasa", "bitDepth": "Bit-sakonera", "sampleRate": "Lagin-tasa", - "albumGain": "Album-irabazia", - "trackGain": "Pista-irabazia", "channels": "Kanalak", "disc": "%{discNumber}. diskoa", "discSubtitle": "Diskoaren azpititulua", @@ -55,7 +53,7 @@ } }, "album": { - "name": "Albuma |||| Albumak", + "name": "Albuma |||| Album", "fields": { "albumArtist": "Albumaren artista", "artist": "Artista", @@ -106,7 +104,7 @@ } }, "artist": { - "name": "Artista |||| Artistak", + "name": "Artista |||| Artista", "fields": { "name": "Izena", "albumCount": "Album kopurua", @@ -119,7 +117,7 @@ "missing": "Ez da aurkitu" }, "roles": { - "albumartist": "Albumeko artista |||| Albumeko artistak", + "albumartist": "Albumeko egilea |||| Albumeko artistak", "artist": "Artista |||| Artistak", "composer": "Konpositorea |||| Konpositoreak", "conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak", @@ -337,7 +335,7 @@ } }, "plugin": { - "name": "Plugina |||| Pluginak", + "name": "Plugina |||| Plugin", "fields": { "id": "IDa", "name": "Izena", @@ -494,7 +492,7 @@ "input": { "file": { "upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.", - "upload_single": "Jaregin edo hautatu igo nahi duzun fitxategia." + "upload_single": "AJaregin edo hautatu igo nahi duzun fitxategia." }, "image": { "upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.", @@ -539,9 +537,9 @@ "skip_nav": "Joan edukira" }, "notification": { - "updated": "Elementua eguneratu da |||| %{smart_count} elementu eguneratu dira", + "updated": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira", "created": "Elementua sortu da", - "deleted": "Elementua ezabatu da |||| %{smart_count} elementu ezabatu dira.", + "deleted": "Elementu bat ezabatu da |||| %{smart_count} elementu ezabatu dira.", "bad_item": "Elementu okerra", "item_doesnt_exist": "Elementua ez dago", "http_error": "Errorea zerbitzariarekin komunikatzerakoan", @@ -590,7 +588,7 @@ "listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da", "listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu", "openIn": { - "lastfm": "Ikusi Last.fm-en", + "lastfm": "Ikusi Last.fm-n", "musicbrainz": "Ikusi MusicBrainz-en" }, "lastfmLink": "Irakurri gehiago…", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index f79d71f94..bbad47bd6 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -38,9 +38,7 @@ "missing": "Puuttuva", "libraryName": "Kirjasto", "composer": "Säveltäjä", - "disc": "Levy %{discNumber}", - "albumGain": "Albumin äänenvoimakkuus", - "trackGain": "Kappaleen äänenvoimakkuus" + "disc": "Levy %{discNumber}" }, "actions": { "addToQueue": "Lisää jonoon", @@ -93,8 +91,7 @@ "addToPlaylist": "Lisää soittolistaan", "download": "Lataa", "info": "Info", - "share": "Jaa", - "refresh": "Päivitä metatiedot" + "share": "Jaa" }, "lists": { "all": "Kaikki", @@ -156,13 +153,11 @@ "newPassword": "Uusi salasana", "token": "Avain", "lastAccessAt": "Viimeisin käyttö", - "libraries": "Kirjastot", - "scrobbleFilter": "Scrobble-suodatin" + "libraries": "Kirjastot" }, "helperTexts": { "name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään", - "libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja", - "scrobbleFilter": "Älykkään soittolistan sääntöihin osumat kappaleet ohitetaan Last.fm-, ListenBrainz- ja skrobbausliitännäisissä. Käyttää samaa JSON-syntaksia ja toimintalogiikkaa kuin älykkäät soittolistat. Esimerkki: {\"all\":[{\"lt\":{\"rating\":4}}]}. Jätä tyhjäksi, jos haluat skrobata kaiken. Paikallisiin toistokertoihin tämä ei vaikuta." + "libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja" }, "notifications": { "created": "Käyttäjä luotu", @@ -176,8 +171,7 @@ "adminAutoLibraries": "Admin-käyttäjillä on automaattisesti pääsy kaikkiin kirjastoihin" }, "validation": { - "librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille", - "invalidScrobbleFilter": "Sääntöjen pitää olla kelvollisia älykkään soittolistan sääntöjä. Rajaus (limit), siirtymä (offset) ja päivitysviive eivät toimi tässä." + "librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille" } }, "player": { @@ -200,9 +194,6 @@ "targetFormat": "Kohde formaatti", "defaultBitRate": "Oletus bittinopeus", "command": "Komento" - }, - "choices": { - "noDefaultBitRate": "Ei mikään" } }, "playlist": { @@ -217,8 +208,7 @@ "songCount": "Kappaleita", "comment": "Kommentti", "sync": "Automaattinen tuonti", - "path": "Tuo", - "starred": "Suosikki" + "path": "Tuo" }, "actions": { "selectPlaylist": "Valitse soittolista:", @@ -411,8 +401,7 @@ "requiredHosts": "Vaaditut palvelimet", "configValidationError": "Määrityksen validointi epäonnistui:", "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen.", - "allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus.", - "idHelp": "Lisäosan ID, joka johdetaan sen tiedostonimestä. Käytä sitä viitatessasi tähän lisäosaan määritelmäasetuksissa, kuten agenteissa." + "allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus." }, "placeholders": { "configKey": "avain", @@ -608,11 +597,7 @@ "coverUploaded": "Kansikuva päivitetty", "coverRemoved": "Kansikuva poistettu", "coverUploadError": "Virhe ladattaessa kansikuvaa", - "coverRemoveError": "Virhe poistettaessa kansikuvaa", - "metadataRefreshStarted": "Metatietoja päivitetään taustalla", - "quickConnectApproved": "", - "quickConnectInvalidCode": "", - "quickConnectError": "" + "coverRemoveError": "Virhe poistettaessa kansikuvaa" }, "menu": { "library": "Kirjasto", @@ -647,15 +632,6 @@ "multipleLibraries": "%{selected} / %{total} kirjastoa", "selectLibraries": "Valitse kirjastot", "none": "Ei mitään" - }, - "onlyFavourites": "Näytä vain suosikit", - "quickConnect": { - "name": "", - "code": "", - "help": "", - "confirm": "", - "continue": "", - "approve": "" } }, "player": { diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index 47f2f4272..d62ca2ab2 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -38,9 +38,7 @@ "missing": "Falta", "libraryName": "Biblioteca", "composer": "Composición", - "disc": "Disco %{discNumber}", - "albumGain": "Gañancia de Album", - "trackGain": "Gañancia de Canción" + "disc": "Disco %{discNumber}" }, "actions": { "addToQueue": "Ao final da cola", @@ -93,8 +91,7 @@ "addToPlaylist": "Engadir a Lista", "download": "Descargar", "info": "Obter info", - "share": "Compartir", - "refresh": "Actualizar metadatos" + "share": "Compartir" }, "lists": { "all": "Todo", @@ -156,13 +153,11 @@ "newPassword": "Novo contrasinal", "token": "Token", "lastAccessAt": "Último acceso", - "libraries": "Bibliotecas", - "scrobbleFilter": "Filtro para scrobble" + "libraries": "Bibliotecas" }, "helperTexts": { "name": "Os cambios no nome aplicaranse a próxima vez que accedas", - "libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto", - "scrobbleFilter": "As cancións que concorden coas regras desta lista intelixente non se envían a Last.fm, ListenBrainz ou complementos similares. O filtro usa a mesma sintaxe JSON e comportamento que as listas de reprodución intelixentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixar baleiro para enviar todo. Non lle afecta ao número de reproducións locais." + "libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto" }, "notifications": { "created": "Creouse a usuaria", @@ -176,8 +171,7 @@ "adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas" }, "validation": { - "librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins", - "invalidScrobbleFilter": "" + "librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins" } }, "player": { @@ -200,9 +194,6 @@ "targetFormat": "Formato de destino", "defaultBitRate": "Taxa de bit por defecto", "command": "Orde" - }, - "choices": { - "noDefaultBitRate": "" } }, "playlist": { @@ -217,8 +208,7 @@ "songCount": "Cancións", "comment": "Comentario", "sync": "Autoimportación", - "path": "Importar desde", - "starred": "Favorita" + "path": "Importar desde" }, "actions": { "selectPlaylist": "Elixe unha lista:", @@ -411,8 +401,7 @@ "requiredHosts": "Servidores requeridos", "configValidationError": "Fallou a comprobación da configuración:", "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.", - "allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura.", - "idHelp": "O ID do complemento, derivado do seu nome de ficheiro. Utilízao cando te refiras ao complemento nas opcións de configuración, como en Agents." + "allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura." }, "placeholders": { "configKey": "clave", @@ -444,7 +433,7 @@ "minValue": "Ten que ter polo menos %{min}", "maxValue": "Ten que ter %{max} ou menos", "number": "Ten que ser un número", - "email": "Ten que ser un correo válido", + "email": "Ten que ser un email válido", "oneOf": "Ten que ser un de: %{options}", "regex": "Ten que ter un formato específico (regexp): %{pattern}", "unique": "Ten que ser único", @@ -518,7 +507,7 @@ } }, "message": { - "about": "Sobre", + "about": "Acerca de", "are_you_sure": "Tes certeza?", "bulk_delete_content": "Tes a certeza de querer borrar a %{name} |||| Tes a certeza de querer eleminar estes %{smart_count} elementos?", "bulk_delete_title": "Eliminar %{name} |||| Eliminar %{smart_count} %{name}", @@ -608,8 +597,7 @@ "coverUploaded": "Subiuse a capa", "coverRemoved": "Retirouse a capa", "coverUploadError": "Erro ao subir a capa", - "coverRemoveError": "Erro ao retirar a capa", - "metadataRefreshStarted": "Actualizar en segundo plano os metadatos" + "coverRemoveError": "Erro ao retirar a capa" }, "menu": { "library": "Biblioteca", @@ -636,7 +624,7 @@ } }, "albumList": "Álbums", - "about": "Sobre", + "about": "Acerca de", "playlists": "Listas de reprodución", "sharedPlaylists": "Listas compartidas", "librarySelector": { @@ -644,8 +632,7 @@ "multipleLibraries": "%{selected} de %{total} Bibliotecas", "selectLibraries": "Seleccionar Bibliotecas", "none": "Ningunha" - }, - "onlyFavourites": "Mostrar só as favoritas" + } }, "player": { "playListsText": "Reproducir cola", diff --git a/resources/i18n/id.json b/resources/i18n/id.json index 762ce4ebb..cdba66663 100644 --- a/resources/i18n/id.json +++ b/resources/i18n/id.json @@ -37,10 +37,7 @@ "sampleRate": "Sample rate", "missing": "Hilang", "libraryName": "Pustaka", - "composer": "Komposer", - "disc": "Disk %{discNumber}", - "albumGain": "Album gain", - "trackGain": "Trek gain" + "composer": "Komposer" }, "actions": { "addToQueue": "Tambah ke antrean", @@ -356,8 +353,7 @@ "allUsers": "Izinkan semua pengguna", "selectedUsers": "Pengguna yang dipilih", "allLibraries": "Izinkan semua pustaka", - "selectedLibraries": "Pustaka dipilih", - "allowWriteAccess": "Izinkan akses tulis" + "selectedLibraries": "Pustaka dipilih" }, "sections": { "status": "Status", @@ -402,8 +398,7 @@ "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.", - "allowWriteAccessHelp": "Ketika diaktifkan, plugin dapat mengubah file di direktori pustaka. Bawaannya, plugin hanya memiliki akses read-only" + "schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid." }, "placeholders": { "configKey": "key", @@ -593,13 +588,7 @@ "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...", - "uploadCover": "Unggah Sampul", - "removeCover": "Hapus Sampul", - "coverUploaded": "Sampul diperbarui", - "coverRemoved": "Sampul dihapus", - "coverUploadError": "Kesalahan mengunggah sampul", - "coverRemoveError": "Kesalahan menghapus sampul" + "startingInstantMix": "Memuat Mix Instan..." }, "menu": { "library": "Pustaka", @@ -685,8 +674,7 @@ "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", - "downloadToml": "Unduh Konfigurasi (TOML)" + "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang" } }, "activity": { diff --git a/resources/i18n/it.json b/resources/i18n/it.json index 656043589..b91c04064 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -17,7 +17,7 @@ "genre": "Genere", "compilation": "Compilation", "year": "Anno", - "size": "Dimensione file", + "size": "Dimensioni", "updatedAt": "Ultimo aggiornamento", "bitRate": "Bitrate", "bitDepth": "Profondità di bit", @@ -98,9 +98,9 @@ "lists": { "all": "Tutti", "random": "Casuali", - "recentlyAdded": "Aggiunti di recente", - "recentlyPlayed": "Riprodotti di recente", - "mostPlayed": "I più riprodotti", + "recentlyAdded": "Aggiunti di Recente", + "recentlyPlayed": "Riprodotti di Recente", + "mostPlayed": "I Più Riprodotti", "starred": "Preferiti", "topRated": "Più votati" } @@ -121,17 +121,17 @@ "roles": { "albumartist": "Artista Album |||| Artisti Album", "artist": "Artista |||| Artisti", - "composer": "Composizione |||| Composizione", - "conductor": "Direzione d'orchestra |||| Direzione d'orchestra", - "lyricist": "Testi |||| Testi", - "arranger": "Arrangiamento |||| Arrangiamento", - "producer": "Produzione |||| Produzione", - "director": "Direzione |||| Direzione", - "engineer": "Ingegneria del suono |||| Ingegneria del suono", + "composer": "Compositore |||| Compositori", + "conductor": "Direttore d'orchestra |||| Direttori d'orchestra", + "lyricist": "Paroliere |||| Parolieri", + "arranger": "Arrangiatore |||| Arrangiatori", + "producer": "Produttore |||| Produttori", + "director": "Direttore |||| Direttori", + "engineer": "Ingegnere del suono |||| Ingegneri del suono", "mixer": "Mixer |||| Mixer", "remixer": "Remixer |||| Remixer", "djmixer": "DJ Mixer |||| DJ Mixer", - "performer": "Esecuzione |||| Esecuzione", + "performer": "Esecutore |||| Esecutori", "maincredit": "Artista Album o Artista |||| Artisti Album o Artisti" }, "actions": { @@ -144,7 +144,7 @@ "name": "Utente |||| Utenti", "fields": { "userName": "Nome utente", - "isAdmin": "Admin", + "isAdmin": "Amministratore", "lastLoginAt": "Ultimo login", "lastAccessAt": "Ultimo accesso", "updatedAt": "Ultimo aggiornamento", @@ -152,13 +152,13 @@ "password": "Password", "createdAt": "Creato il", "changePassword": "Cambiare la password?", - "currentPassword": "Password attuale", - "newPassword": "Nuova password", + "currentPassword": "Password Attuale", + "newPassword": "Nuova Password", "token": "Token", "libraries": "Librerie" }, "helperTexts": { - "name": "Le modifiche al tuo nome verranno mostrate solo al prossimo accesso", + "name": "Le modifiche effettuate al tuo nome verranno mostrate al prossimo accesso", "libraries": "Seleziona librerie specifiche per questo utente, o lascia vuoto per usare le librerie predefinite" }, "notifications": { @@ -167,13 +167,13 @@ "deleted": "Utente eliminato" }, "validation": { - "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non admin" + "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non amministratori" }, "message": { - "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz.", + "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz", "clickHereForToken": "Clicca qui per ottenere il tuo token", "selectAllLibraries": "Seleziona tutte le librerie", - "adminAutoLibraries": "Gli utenti admin hanno automaticamente accesso a tutte le librerie" + "adminAutoLibraries": "Gli utenti amministratori hanno automaticamente accesso a tutte le librerie" } }, "player": { @@ -203,29 +203,28 @@ "fields": { "name": "Nome", "duration": "Durata", - "ownerName": "Di", + "ownerName": "Creatore", "public": "Pubblica", "updatedAt": "Ultimo aggiornamento", "createdAt": "Data creazione", "songCount": "Tracce", "comment": "Commento", "sync": "Importazione automatica", - "path": "Importa da", - "starred": "Preferita" + "path": "Importa da" }, "actions": { "selectPlaylist": "Seleziona una playlist:", "addNewPlaylist": "Crea \"%{name}\"", "export": "Esporta", "saveQueue": "Salva la coda nella playlist", - "makePublic": "Rendi pubblica", - "makePrivate": "Rendi privata", + "makePublic": "Rendi Pubblica", + "makePrivate": "Rendi Privata", "searchOrCreate": "Cerca playlist o digita per crearne una nuova...", "pressEnterToCreate": "Premi Invio per creare una nuova playlist", "removeFromSelection": "Rimuovi dalla selezione" }, "message": { - "duplicate_song": "Aggiungi tracce duplicate", + "duplicate_song": "Aggiungere i duplicati", "song_exist": "Si stanno aggiungendo dei duplicati nella playlist. Vuoi aggiungerli o saltarli?", "noPlaylistsFound": "Nessuna playlist trovata", "noPlaylists": "Nessuna playlist disponibile" @@ -332,7 +331,7 @@ "pathInvalid": "Percorso della libreria non valido" }, "messages": { - "deleteConfirm": "Vuoi eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", + "deleteConfirm": "Sei sicuro di voler eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", "scanInProgress": "Scansione in corso...", "noLibrariesAssigned": "Nessuna libreria assegnata a questo utente" } @@ -368,7 +367,7 @@ "configuration": "Configurazione", "manifest": "Manifest", "usersPermission": "Permessi utenti", - "libraryPermission": "Permessi librerie" + "libraryPermission": "Permesso libreria" }, "status": { "enabled": "Abilitato", @@ -401,10 +400,10 @@ "allUsersHelp": "Se abilitato, il plugin avrà accesso a tutti gli utenti, inclusi quelli creati in futuro.", "noUsers": "Nessun utente selezionato", "permissionReason": "Motivo", - "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona a quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", + "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", "allLibrariesHelp": "Se abilitato, il plugin avrà accesso a tutte le librerie, incluse quelle create in futuro.", "noLibraries": "Nessuna libreria selezionata", - "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona a quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", + "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", "allowWriteAccessHelp": "Se abilitato, il plugin può modificare i file nelle directory della libreria. Per impostazione predefinita, i plugin hanno accesso in sola lettura.", "requiredHosts": "Host richiesti" }, @@ -417,9 +416,9 @@ "ra": { "auth": { "welcome1": "Grazie per aver installato Navidrome!", - "welcome2": "Per iniziare, crea un account amministratore", + "welcome2": "Per iniziare, crea un amministratore", "confirmPassword": "Conferma la password", - "buttonCreateAdmin": "Crea admin", + "buttonCreateAdmin": "Crea amministratore", "auth_check_error": "Per favore accedi per continuare", "user_menu": "Profilo", "username": "Nome utente", @@ -489,8 +488,8 @@ "loading": "Caricamento in corso", "not_found": "Non trovato", "show": "%{name} #%{id}", - "empty": "Ancora niente %{name}.", - "invite": "Vuoi aggiungerne?" + "empty": "Nessun %{name} per adesso.", + "invite": "Vuoi aggiungerne uno?" }, "input": { "file": { @@ -513,10 +512,10 @@ }, "message": { "about": "Informazioni", - "are_you_sure": "Vuoi procedere?", - "bulk_delete_content": "Vuoi rimuovere questo %{name}? |||| Vuoi rimuovere questi %{smart_count} elementi?", + "are_you_sure": "Sei sicuro?", + "bulk_delete_content": "Sei sicuro di voler rimuovere questo %{name}? |||| Sei sicuro di voler rimuovere questi %{smart_count} elementi?", "bulk_delete_title": "Rimuovi %{name} |||| Rimuovi %{smart_count} %{name}", - "delete_content": "Vuoi eliminare questo elemento?", + "delete_content": "Sei sicuro di voler eliminare questo elemento?", "delete_title": "Rimuovi %{name} #%{id}", "details": "Dettagli", "error": "Un errore dal lato client ha impedito il completamento della tua richiesta.", @@ -525,7 +524,7 @@ "no": "No", "not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.", "yes": "Sì", - "unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ignorarle?" + "unsaved_changes": "Alcune modifiche non sono state salvate. Sei sicuro di volerle ignorare?" }, "navigation": { "no_results": "Nessun risultato trovato", @@ -575,11 +574,11 @@ "noTopSongsFound": "Nessun brano più ascoltato trovato", "noPlaylistsAvailable": "Nessuna disponibile", "delete_user_title": "Rimuovi utente '%{name}'", - "delete_user_content": "Vuoi rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", + "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", "remove_missing_title": "Rimuovi i file mancanti", - "remove_missing_content": "Vuoi rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_missing_content": "Sei sicuro di voler rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "remove_all_missing_title": "Rimuovi tutti i file mancanti", - "remove_all_missing_content": "Vuoi rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_all_missing_content": "Sei sicuro di voler rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "notifications_blocked": "Hai bloccato le notifiche per questo sito nelle tue impostazioni del browser", "notifications_not_available": "Questo browser non supporta le notifiche desktop o non stai accedendo a Navidrome tramite HTTPS", "lastfmLinkSuccess": "Collegamento a Last.fm riuscito e scrobbling abilitato", @@ -620,7 +619,7 @@ "options": { "theme": "Tema", "language": "Lingua", - "defaultView": "Vista predefinita", + "defaultView": "Vista Predefinita", "desktop_notifications": "Notifiche desktop", "lastfmNotConfigured": "La chiave API di Last.fm non è configurata", "lastfmScrobbling": "Esegui lo scrobbling tramite Last.fm", @@ -636,22 +635,21 @@ }, "albumList": "Album", "playlists": "Playlist", - "sharedPlaylists": "Playlist condivise", - "about": "Info", - "onlyFavourites": "Mostra solo i preferiti" + "sharedPlaylists": "Playlist Condivise", + "about": "Info" }, "player": { "playListsText": "Coda", "openText": "Apri", "closeText": "Chiudi", - "notContentText": "Niente musica", + "notContentText": "Nessuna traccia", "clickToPlayText": "Clicca per riprodurre", "clickToPauseText": "Clicca per mettere in pausa", "nextTrackText": "Traccia successiva", "previousTrackText": "Traccia precedente", "reloadText": "Ricarica", "volumeText": "Volume", - "toggleLyricText": "Mostra/nascondi testo", + "toggleLyricText": "Mostra testo", "toggleMiniModeText": "Minimizza", "destroyText": "Distruggi", "downloadText": "Scarica", @@ -661,7 +659,7 @@ "playModeText": { "order": "In ordine", "orderLoop": "Ripeti", - "singleLoop": "Ripeti traccia", + "singleLoop": "Ripeti una volta", "shufflePlay": "Casuale" } }, @@ -669,7 +667,7 @@ "links": { "homepage": "Sito web", "source": "Codice sorgente", - "featureRequests": "Proponi idee", + "featureRequests": "Richieste", "lastInsightsCollection": "Ultima raccolta dati", "insights": { "disabled": "Disabilitato", @@ -695,7 +693,7 @@ }, "activity": { "title": "Attività", - "totalScanned": "Totale cartelle scansionate", + "totalScanned": "Cartelle scansionate totali", "quickScan": "Rapida", "fullScan": "Completa", "selectiveScan": "Selettiva", @@ -711,17 +709,17 @@ "minutesAgo": "%{smart_count} minuto fa |||| %{smart_count} minuti fa" }, "help": { - "title": "Scorciatoie da tastiera di Navidrome", + "title": "Scorciatoie da Tastiera di Navidrome", "hotkeys": { "show_help": "Mostra questa schermata", "toggle_menu": "Mostra/Nascondi la barra laterale", "toggle_play": "Riproduzione/Pausa", - "prev_song": "Traccia precedente", - "next_song": "Traccia successiva", + "prev_song": "Traccia Precedente", + "next_song": "Traccia Successiva", "current_song": "Vai alla traccia corrente", - "vol_up": "Alza il volume", - "vol_down": "Abbassa il volume", + "vol_up": "Alza il Volume", + "vol_down": "Abbassa il Volume", "toggle_love": "Aggiungi questa traccia ai preferiti" } } -} +} \ No newline at end of file diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json index 83cd7822b..29975b92b 100644 --- a/resources/i18n/ja.json +++ b/resources/i18n/ja.json @@ -5,53 +5,48 @@ "name": "曲", "fields": { "albumArtist": "アルバムアーティスト", - "duration": "再生時間", + "duration": "長さ", "trackNumber": "#", - "playCount": "再生回数", + "playCount": "再生数", "title": "タイトル", "artist": "アーティスト", - "composer": "作曲", "album": "アルバム", "path": "ファイルパス", - "libraryName": "ライブラリ", "genre": "ジャンル", - "compilation": "コンピレーション", + "compilation": "Compilation", "year": "年", "size": "ファイルサイズ", - "updatedAt": "更新日時", + "updatedAt": "更新日", "bitRate": "ビットレート", - "bitDepth": "ビット深度", - "sampleRate": "サンプリングレート", - "albumGain": "アルバムゲイン", - "trackGain": "トラックゲイン", - "channels": "チャンネル", - "disc": "ディスク %{discNumber}", "discSubtitle": "ディスクサブタイトル", "starred": "お気に入り", "comment": "コメント", - "rating": "評価", - "quality": "形式", + "rating": "レート", + "quality": "品質", "bpm": "BPM", - "playDate": "最終再生日時", - "createdAt": "追加日時", - "grouping": "グループ化", + "playDate": "最後の再生", + "channels": "チャンネル", + "createdAt": "追加日", + "grouping": "グループ分け", "mood": "ムード", - "participants": "参加者", + "participants": "追加参加者", "tags": "追加タグ", - "mappedTags": "マッピングされたタグ", - "rawTags": "未加工タグ", - "missing": "不明" + "mappedTags": "マッピング済みタグ", + "rawTags": "未処理タグ", + "bitDepth": "ビット深度", + "sampleRate": "サンプリングレート", + "missing": "不明", + "libraryName": "ライブラリ" }, "actions": { "addToQueue": "最後に再生", - "playNow": "今すぐ再生", + "playNow": "すぐに再生", "addToPlaylist": "プレイリストに追加", - "showInPlaylist": "プレイリストで表示", - "shuffleAll": "すべてシャッフル", + "shuffleAll": "全曲シャッフル", "download": "ダウンロード", "playNext": "次に再生", "info": "詳細", - "instantMix": "インスタントミックス" + "showInPlaylist": "含まれるプレイリスト" } }, "album": { @@ -59,49 +54,48 @@ "fields": { "albumArtist": "アルバムアーティスト", "artist": "アーティスト", - "duration": "再生時間", - "songCount": "曲数", - "playCount": "再生回数", - "size": "ファイルサイズ", - "name": "アルバム名", - "libraryName": "ライブラリ", + "duration": "長さ", + "songCount": "曲", + "playCount": "再生数", + "name": "名前", "genre": "ジャンル", - "compilation": "コンピレーション", + "compilation": "Compilation", "year": "年", - "date": "録音日", - "originalDate": "オリジナル発売日", - "releaseDate": "発売日", - "releases": "リリース", - "released": "リリース済み", - "updatedAt": "更新日時", + "updatedAt": "更新日", "comment": "コメント", - "rating": "評価", - "createdAt": "追加日時", - "recordLabel": "レーベル", + "rating": "レート", + "createdAt": "追加日", + "size": "サイズ", + "originalDate": "オリジナルの日付", + "releaseDate": "リリース日", + "releases": "リリース", + "released": "リリース", + "recordLabel": "ラベル", "catalogNum": "カタログ番号", - "releaseType": "リリースタイプ", - "grouping": "グループ化", + "releaseType": "タイプ", + "grouping": "グループ分け", "media": "メディア", "mood": "ムード", - "missing": "不明" + "date": "録音日", + "missing": "不明", + "libraryName": "ライブラリ" }, "actions": { - "playAll": "すべて再生", + "playAll": "再生", "playNext": "次に再生", "addToQueue": "最後に再生", - "share": "共有", "shuffle": "シャッフル", - "addToPlaylist": "プレイリストに追加", + "addToPlaylist": "プレイリストへ追加", "download": "ダウンロード", "info": "詳細", - "refresh": "メタデータを更新" + "share": "共有" }, "lists": { - "all": "すべて", + "all": "全て", "random": "ランダム", "recentlyAdded": "最近の追加", "recentlyPlayed": "最近の再生", - "mostPlayed": "最多再生", + "mostPlayed": "最も再生", "starred": "お気に入り", "topRated": "高評価" } @@ -112,33 +106,33 @@ "name": "名前", "albumCount": "アルバム枚数", "songCount": "曲数", - "size": "サイズ", - "playCount": "再生回数", - "rating": "評価", + "playCount": "再生数", + "rating": "レート", "genre": "ジャンル", + "size": "サイズ", "role": "役割", "missing": "不明" }, "roles": { "albumartist": "アルバムアーティスト", "artist": "アーティスト", - "composer": "作曲", - "conductor": "指揮", - "lyricist": "作詞", - "arranger": "編曲", + "composer": "作曲家", + "conductor": "指揮者", + "lyricist": "作詞家", + "arranger": "編曲者", "producer": "プロデューサー", - "director": "監督", + "director": "ディレクター", "engineer": "エンジニア", "mixer": "ミキサー", "remixer": "リミキサー", "djmixer": "DJ ミキサー", - "performer": "演奏", - "maincredit": "アルバムアーティストまたはアーティスト" + "performer": "演奏者", + "maincredit": "アルバムアーティストもしくはアーティスト" }, "actions": { - "topSongs": "人気曲", "shuffle": "シャッフル", - "radio": "ラジオ" + "radio": "ラジオ", + "topSongs": "トップソング" } }, "user": { @@ -146,58 +140,55 @@ "fields": { "userName": "ユーザー名", "isAdmin": "管理者", - "lastLoginAt": "最終ログイン日時", - "lastAccessAt": "最終アクセス日時", - "updatedAt": "更新日時", + "lastLoginAt": "最終ログイン", + "updatedAt": "更新日", "name": "名前", "password": "パスワード", - "createdAt": "作成日時", + "createdAt": "作成日", "changePassword": "パスワードを変更しますか?", "currentPassword": "現在のパスワード", "newPassword": "新しいパスワード", "token": "トークン", - "libraries": "ライブラリ", - "scrobbleFilter": "Scrobble フィルター" + "lastAccessAt": "最終アクセス", + "libraries": "ライブラリ" }, "helperTexts": { - "name": "ユーザー名の変更は、次回ログイン時に反映されます", - "libraries": "このユーザー用に特定のライブラリを選択するか、デフォルトのライブラリを使用する場合は空のままにしてください", - "scrobbleFilter": "これらのスマートプレイリストのルールに一致する曲は、Last.fm、ListenBrainz、または Scrobble プラグインに送信されません。スマートプレイリストと同じ JSON 構文と動作を使用します。例: {\"all\":[{\"lt\":{\"rating\":4}}]}。すべてを Scrobble する場合は空欄のままにしてください。ローカルでの再生回数には影響しません。" + "name": "名前の変更は次回ログイン以降反映されます", + "libraries": "このユーザーに対して特定ライブラリを選択するか、デフォルトのライブラリを使用する場合は空欄のままにします" }, "notifications": { "created": "ユーザーが作成されました", "updated": "ユーザーが更新されました", "deleted": "ユーザーが削除されました" }, - "validation": { - "librariesRequired": "管理者以外のユーザーについては、少なくとも 1 つのライブラリを選択する必要があります", - "invalidScrobbleFilter": "有効なスマートプレイリストのルールである必要があります。上限、オフセット、更新遅延はサポートされていません。" - }, "message": { - "listenBrainzToken": "ListenBrainz のユーザートークンを入力してください。", - "clickHereForToken": "トークンを取得するには、ここをクリックしてください", - "selectAllLibraries": "すべてのライブラリを選択", - "adminAutoLibraries": "管理者ユーザーは、すべてのライブラリに自動的にアクセスできます" + "listenBrainzToken": "ListenBrainzユーザートークンを入力", + "clickHereForToken": "ここをクリックしトークンを入手", + "selectAllLibraries": "全てのライブラリを選択", + "adminAutoLibraries": "管理者ユーザーは自動的にすべてのライブラリにアクセスできます" + }, + "validation": { + "librariesRequired": "管理者以外のユーザーには少なくとも1つのライブラリを選択する必要があります" } }, "player": { - "name": "プレーヤー", + "name": "プレイヤー", "fields": { "name": "名前", "transcodingId": "トランスコード", "maxBitRate": "最大ビットレート", "client": "クライアント", - "userName": "ユーザー名", - "lastSeen": "最終アクセス日時", + "userName": "ユーザ名", + "lastSeen": "最後の利用", "reportRealPath": "実際のファイルパスを返す", - "scrobbleEnabled": "外部サービスに Scrobble する" + "scrobbleEnabled": "他のサービスへscrobbleする" } }, "transcoding": { "name": "トランスコード", "fields": { "name": "名前", - "targetFormat": "ターゲット形式", + "targetFormat": "対象フォーマット", "defaultBitRate": "デフォルトビットレート", "command": "コマンド" } @@ -206,46 +197,45 @@ "name": "プレイリスト", "fields": { "name": "名前", - "duration": "再生時間", + "duration": "時間", "ownerName": "所有者", "public": "公開", - "updatedAt": "更新日時", - "createdAt": "作成日時", - "songCount": "曲数", + "updatedAt": "更新日", + "createdAt": "作成日", + "songCount": "曲", "comment": "コメント", - "sync": "同期", - "path": "インポートパス", - "starred": "お気に入り" + "sync": "自動インポート", + "path": "インポート元" }, "actions": { - "selectPlaylist": "プレイリストを選択:", - "addNewPlaylist": "\"%{name}\" を作成", + "selectPlaylist": "プレイリストを選択", + "addNewPlaylist": "'%{name}' を作成", "export": "エクスポート", - "saveQueue": "キューをプレイリストに保存", - "makePublic": "公開にする", + "makePublic": "公開する", "makePrivate": "非公開にする", - "searchOrCreate": "プレイリストを検索するか、入力して新規作成します...", - "pressEnterToCreate": "Enter キーを押して新しいプレイリストを作成します", - "removeFromSelection": "選択から除外" + "saveQueue": "キューをプレイリストに保存", + "searchOrCreate": "プレイリストを検索または入力して新規作成...", + "pressEnterToCreate": "Enterキーを押して新しいプレイリストを作成", + "removeFromSelection": "選択から削除" }, "message": { - "duplicate_song": "重複している曲を追加", - "song_exist": "プレイリストに重複する曲が追加されようとしています。重複する曲を追加しますか、それともスキップしますか?", - "noPlaylistsFound": "プレイリストが見つかりませんでした", - "noPlaylists": "利用可能なプレイリストがありません" + "duplicate_song": "重複する曲を追加", + "song_exist": "既にプレイリストに存在する曲です。追加しますか?", + "noPlaylistsFound": "プレイリストが見つかりません", + "noPlaylists": "利用可能なプレイリストはありません" } }, "radio": { "name": "ラジオ", "fields": { "name": "名前", - "streamUrl": "ストリーム URL", - "homePageUrl": "ホームページ URL", - "updatedAt": "更新日時", - "createdAt": "作成日時" + "streamUrl": "配信URL", + "homePageUrl": "ホームページURL", + "updatedAt": "更新日", + "createdAt": "作成日" }, "actions": { - "playNow": "今すぐ再生" + "playNow": "すぐに再生" } }, "share": { @@ -254,57 +244,55 @@ "username": "共有者", "url": "URL", "description": "説明", - "downloadable": "ダウンロードを許可しますか?", "contents": "コンテンツ", - "expiresAt": "有効期限", - "lastVisitedAt": "最終閲覧日時", - "visitCount": "閲覧回数", - "format": "形式", + "expiresAt": "期限切れ", + "lastVisitedAt": "最後の訪問", + "visitCount": "訪問回数", + "format": "フォーマット", "maxBitRate": "最大ビットレート", - "updatedAt": "更新日時", - "createdAt": "作成日時" - }, - "notifications": {}, - "actions": {} + "updatedAt": "更新日", + "createdAt": "作成日", + "downloadable": "ダウンロードを許可しますか?" + } }, "missing": { - "name": "欠落ファイル", - "empty": "欠落しているファイルはありません", + "name": "欠落したファイル", "fields": { - "path": "ファイルパス", - "size": "ファイルサイズ", - "libraryName": "ライブラリ名", - "updatedAt": "更新日時" + "path": "パス", + "size": "サイズ", + "updatedAt": "欠落日", + "libraryName": "ライブラリ" }, "actions": { "remove": "削除", - "remove_all": "すべて削除" + "remove_all": "全て削除" }, "notifications": { "removed": "欠落ファイルが削除されました" - } + }, + "empty": "ファイルの欠落はありません" }, "library": { "name": "ライブラリ", "fields": { "name": "名前", "path": "パス", - "remotePath": "リモートスキャン", - "lastScanAt": "最終スキャン日時", + "remotePath": "リモートパス", + "lastScanAt": "最終スキャン", "songCount": "曲数", - "albumCount": "アルバム枚数", + "albumCount": "アルバム数", "artistCount": "アーティスト数", - "totalSongs": "総曲数", - "totalAlbums": "総アルバム枚数", - "totalArtists": "総アーティスト数", - "totalFolders": "フォルダー総数", - "totalFiles": "ファイル総数", - "totalMissingFiles": "欠落ファイル数", + "totalSongs": "曲数", + "totalAlbums": "アルバム数", + "totalArtists": "アーティスト数", + "totalFolders": "フォルダー数", + "totalFiles": "ファイル数", + "totalMissingFiles": "欠落したファイル", "totalSize": "合計サイズ", - "totalDuration": "総再生時間", - "defaultNewUsers": "新規ユーザーのデフォルト設定", - "createdAt": "作成日時", - "updatedAt": "更新日時" + "totalDuration": "合計時間", + "defaultNewUsers": "新規ユーザーに対するデフォルト", + "createdAt": "作成日", + "updatedAt": "更新日" }, "sections": { "basic": "基本情報", @@ -312,172 +300,95 @@ }, "actions": { "scan": "ライブラリをスキャン", + "manageUsers": "ユーザーアクセス管理", + "viewDetails": "詳細を表示", "quickScan": "クイックスキャン", - "fullScan": "フルスキャン", - "manageUsers": "ユーザーを管理", - "viewDetails": "詳細を表示" + "fullScan": "フルスキャン" }, "notifications": { "created": "ライブラリが正常に作成されました", "updated": "ライブラリが正常に更新されました", "deleted": "ライブラリが正常に削除されました", - "scanStarted": "ライブラリのスキャンが開始されました", - "quickScanStarted": "クイックスキャンが開始されました", - "fullScanStarted": "フルスキャンが開始されました", - "scanError": "スキャンを開始できませんでした。ログを確認してください", - "scanCompleted": "ライブラリのスキャンが完了しました" + "scanStarted": "スキャンを開始しました", + "scanCompleted": "スキャンが完了しました", + "quickScanStarted": "クイックスキャンを開始しました", + "fullScanStarted": "フルスキャンを開始しました", + "scanError": "スキャン開始中にエラーが発生。ログを確認してください" }, "validation": { - "nameRequired": "ライブラリ名は必須です", - "pathRequired": "ライブラリパスは必須です", + "nameRequired": "ライブラリの名前が必要です", + "pathRequired": "ライブラリのパスが必要です", "pathNotDirectory": "ライブラリパスはディレクトリである必要があります", - "pathNotFound": "ライブラリパスが見つかりません", - "pathNotAccessible": "ライブラリパスにアクセスできません", + "pathNotFound": "ライブラリのパスが見つかりません", + "pathNotAccessible": "ライブラリパスへアクセスできません", "pathInvalid": "無効なライブラリパス" }, "messages": { - "deleteConfirm": "このライブラリを削除してもよろしいですか?これにより、関連するすべてのデータおよびユーザーのアクセス権が削除されます。", + "deleteConfirm": "このライブラリを削除しますか?関連する全てのデータとユーザーアクセスが削除されます。", "scanInProgress": "スキャン中...", - "noLibrariesAssigned": "このユーザーに割り当てられたライブラリはありません" - } - }, - "plugin": { - "name": "プラグイン", - "fields": { - "id": "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": { - "idHelp": "ファイル名から生成されるプラグイン ID です。エージェントなどの設定オプションでこのプラグインを参照する際に使用します。", - "configHelp": "キーと値のペアを使用してプラグインを設定します。設定が不要な場合は、空のままにしてください。", - "configValidationError": "設定の検証に失敗しました:", - "schemaRenderError": "設定フォームを表示できません。プラグインのスキーマが無効である可能性があります。", - "clickPermissions": "詳細を表示するには、「権限」をクリックしてください", - "noConfig": "設定がありません", - "allUsersHelp": "有効にすると、このプラグインは、今後作成されるユーザーも含め、すべてのユーザーにアクセスできるようになります。", - "noUsers": "ユーザーが選択されていません", - "permissionReason": "理由", - "usersRequired": "このプラグインは、ユーザー情報へのアクセスを必要とします。プラグインがアクセスできるユーザーを選択するか、「すべてのユーザーを許可」を有効にしてください。", - "allLibrariesHelp": "有効にすると、このプラグインは、今後作成されるライブラリも含め、すべてのライブラリにアクセスできるようになります。", - "noLibraries": "ライブラリが選択されていません", - "librariesRequired": "このプラグインは、ライブラリ情報へのアクセスを必要とします。プラグインがアクセスできるライブラリを選択するか、「すべてのライブラリを許可」を有効にしてください。", - "allowWriteAccessHelp": "有効にすると、プラグインはライブラリディレクトリ内のファイルを変更できるようになります。デフォルトでは、プラグインのアクセス権限は読み取り専用に設定されています。", - "requiredHosts": "必須ホスト" - }, - "placeholders": { - "configKey": "キー", - "configValue": "値" + "noLibrariesAssigned": "このユーザーに割り当てられているライブラリはありません" } } }, "ra": { "auth": { - "welcome1": "Navidrome をインストールしていただき、ありがとうございます!", - "welcome2": "まずは、管理者ユーザーを作成してください", + "welcome1": "Navidromeをインストールいただきありがとうございます!", + "welcome2": "管理ユーザーを作成して始めましょう", "confirmPassword": "パスワードの確認", - "buttonCreateAdmin": "管理者を作成", - "auth_check_error": "続行するにはログインしてください", - "user_menu": "プロファイル", + "buttonCreateAdmin": "管理者の作成", + "auth_check_error": "認証に失敗しました。再度ログインしてください", + "user_menu": "プロフィール", "username": "ユーザー名", "password": "パスワード", "sign_in": "ログイン", - "sign_in_error": "認証に失敗しました。もう一度お試しください", + "sign_in_error": "認証に失敗しました。入力を確認してください", "logout": "ログアウト", - "insightsCollectionNote": "Navidrome では、プロジェクトの改善に役立てるため、匿名化された利用データを収集しています。詳細の確認やオプトアウト(収集の停止)を行うには、[こちら]をクリックしてください" + "insightsCollectionNote": "Navidromeでは、プロジェクトの改善に役立てるため、匿名の利用データを収集しています。詳しくは [here] をクリックしてください。" }, "validation": { - "invalidChars": "英数字のみを使用してください", + "invalidChars": "文字と数字のみを使用してください", "passwordDoesNotMatch": "パスワードが一致しません", "required": "必須", - "minLength": "%{min} 文字以上である必要があります", - "maxLength": "%{max} 文字以下である必要があります", - "minValue": "%{min} 桁以上である必要があります", - "maxValue": "%{max} 桁以下である必要があります", + "minLength": "%{min}文字以上である必要があります", + "maxLength": "%{max}文字以下である必要があります", + "minValue": "%{min}以上である必要があります", + "maxValue": "%{max}以下である必要があります", "number": "数字である必要があります", - "email": "有効なメールアドレスである必要があります", + "email": "メールアドレスである必要があります", "oneOf": "次のいずれかである必要があります: %{options}", - "regex": "特定の形式(正規表現)に一致する必要があります: %{pattern}", + "regex": "次の正規表現形式にする必要があります: %{pattern}", "unique": "一意である必要があります", - "url": "有効な URL である必要があります" + "url": "有効なURLを入力してください" }, "action": { - "add_filter": "フィルターを追加", + "add_filter": "検索条件", "add": "追加", "back": "戻る", - "bulk_actions": "1 件選択中 |||| %{smart_count} 件選択中", - "bulk_actions_mobile": "1 |||| %{smart_count}", + "bulk_actions": "%{smart_count}件選択", "cancel": "キャンセル", - "clear_input_value": "値を消去", + "clear_input_value": "空にする", "clone": "複製", "confirm": "確認", "create": "作成", "delete": "削除", "edit": "編集", "export": "エクスポート", - "list": "リスト", + "list": "一覧", "refresh": "更新", - "remove_filter": "このフィルターを削除", + "remove_filter": "検索条件を削除", "remove": "削除", "save": "保存", "search": "検索", - "show": "表示", - "sort": "並べ替え", + "show": "詳細", + "sort": "並び替え", "undo": "元に戻す", - "expand": "展開", + "expand": "開く", "close": "閉じる", - "open_menu": "メニューを開く", - "close_menu": "メニューを閉じる", + "open_menu": "開く", + "close_menu": "閉じる", "unselect": "選択解除", "skip": "スキップ", + "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "共有", "download": "ダウンロード" }, @@ -491,71 +402,71 @@ "edit": "%{name} #%{id}", "error": "問題が発生しました", "list": "%{name}", - "loading": "読み込み中", + "loading": "読込中", "not_found": "見つかりませんでした", "show": "%{name} #%{id}", - "empty": "%{name} はまだありません。", - "invite": "追加してもよろしいですか?" + "empty": "%{name}はありません", + "invite": "作成しますか?" }, "input": { "file": { - "upload_several": "ファイルをドロップしてアップロードするか、クリックしてファイルを選択してください。", - "upload_single": "ファイルをドロップしてアップロードするか、クリックして選択してください。" + "upload_several": "アップロードするファイルをドロップ、または選択してください", + "upload_single": "アップロードするファイルをドロップ、または選択してください" }, "image": { - "upload_several": "画像をドロップしてアップロードするか、クリックして画像を選択してください。", - "upload_single": "画像をドロップしてアップロードするか、クリックして画像を選択してください。" + "upload_several": "アップロードする画像をドロップ、または選択してください", + "upload_single": "アップロードする画像をドロップ、または選択してください" }, "references": { - "all_missing": "参照データが見つかりませんでした。", - "many_missing": "参照データのうち、少なくとも 1 件が利用できなくなっています。", - "single_missing": "参照データは現在利用できません。" + "all_missing": "データが利用できなくなりました", + "many_missing": "選択したデータが利用できなくなりました", + "single_missing": "選択したデータが利用できなくなりました" }, "password": { - "toggle_visible": "パスワードを非表示", - "toggle_hidden": "パスワードを表示" + "toggle_visible": "非表示", + "toggle_hidden": "表示" } }, "message": { - "about": "概要", - "are_you_sure": "よろしいですか?", - "bulk_delete_content": "%{name} を削除してもよろしいですか? |||| %{smart_count} 件の項目を削除してもよろしいですか?", - "bulk_delete_title": "%{name} を削除 |||| %{smart_count} 件の %{name} を削除", - "delete_content": "この項目を削除してもよろしいですか?", + "about": "詳細", + "are_you_sure": "本当によろしいですか?", + "bulk_delete_content": "%{name} を削除してよろしいですか? |||| 選択した %{smart_count}件のアイテムを削除してよろしいですか?", + "bulk_delete_title": "%{name} を削除 |||| %{name} %{smart_count}件を削除", + "delete_content": "削除してよろしいですか?", "delete_title": "%{name} #%{id} を削除", "details": "詳細", - "error": "クライアント側でエラーが発生したため、リクエストを完了できませんでした。", - "invalid_form": "フォームが無効です。エラーを確認してください", - "loading": "ページを読み込んでいます。しばらくお待ちください", + "error": "クライアントエラーが発生し、処理を完了できませんでした", + "invalid_form": "入力値に誤りがあります。エラーメッセージを確認してください", + "loading": "読み込み中です。しばらくお待ちください", "no": "いいえ", - "not_found": "URL を間違えて入力したか、無効なリンクをクリックした可能性があります。", + "not_found": "間違ったURLを入力したか、間違ったリンクを辿りました", "yes": "はい", - "unsaved_changes": "変更の一部が保存されませんでした。変更を破棄してもよろしいですか?" + "unsaved_changes": "行った変更が保存されていません。このページから移動してよろしいですか?" }, "navigation": { - "no_results": "検索結果はありません", - "no_more_results": "ページ番号 %{page} は範囲外です。前のページに戻ってください。", - "page_out_of_boundaries": "ページ番号 %{page} は範囲外です", - "page_out_from_end": "最後のページより先には進めません", - "page_out_from_begin": "ページ 1 より前には戻れません", + "no_results": "結果が見つかりませんでした", + "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": "1 ページあたりの表示件数:", + "page_rows_per_page": "表示件数:", "next": "次", "prev": "前", - "skip_nav": "コンテンツへスキップ" + "skip_nav": "スキップ" }, "notification": { - "updated": "更新されました |||| %{smart_count} 件の項目が更新されました", - "created": "作成されました", - "deleted": "削除されました |||| %{smart_count} 件の項目が削除されました", - "bad_item": "項目が無効です", - "item_doesnt_exist": "項目が存在しません", - "http_error": "サーバー通信エラーが発生しました", - "data_provider_error": "dataProvider のエラーが発生しました。詳細についてはコンソールを確認してください。", - "i18n_error": "指定された言語の翻訳を読み込めません", - "canceled": "操作がキャンセルされました", - "logged_out": "セッションが終了しました。再接続してください。", - "new_version": "新しいバージョンが利用可能になりました!このウィンドウを更新してください。" + "updated": "更新しました |||| %{smart_count} 件更新しました", + "created": "作成しました", + "deleted": "削除しました |||| %{smart_count} 件削除しました", + "bad_item": "データが不正です", + "item_doesnt_exist": "データが存在しませんでした", + "http_error": "通信エラーが発生しました", + "data_provider_error": "dataProviderエラー。詳細はコンソールを確認してください", + "i18n_error": "翻訳ファイルが読み込めませんでした", + "canceled": "元に戻しました", + "logged_out": "認証に失敗しました。再度ログインしてください", + "new_version": "新しいバージョンが利用可能です!ページを更新してください。" }, "toggleFieldsMenu": { "columnsToDisplay": "表示列", @@ -565,59 +476,45 @@ } }, "message": { - "uploadCover": "カバーをアップロード", - "removeCover": "カバーを削除", - "coverUploaded": "カバーアートが更新されました", - "coverRemoved": "カバーアートが削除されました", - "coverUploadError": "カバーアートのアップロード中にエラーが発生しました", - "coverRemoveError": "カバーアートの削除中にエラーが発生しました", - "metadataRefreshStarted": "メタデータの更新が開始されました", - "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 との連携に成功し、Scrobbling 機能が有効になりました", - "lastfmLinkFailure": "Last.fm との連携に失敗しました", - "lastfmUnlinkSuccess": "Last.fm との連携が解除され、Scrobbling 機能が無効になりました", - "lastfmUnlinkFailure": "Last.fm との連携解除に失敗しました", - "listenBrainzLinkSuccess": "ListenBrainz との連携に成功し、ユーザー %{user} として Scrobbling 機能が有効になりました", - "listenBrainzLinkFailure": "ListenBrainz との連携に失敗しました: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz との連携が解除され、Scrobbling 機能が無効になりました", - "listenBrainzUnlinkFailure": "ListenBrainz との連携解除に失敗しました", + "note": "注意", + "transcodingDisabled": "セキュリティ上の理由から、Web インターフェイスからのトランスコード設定は無効になっています。\nこれを設定したい場合、環境変数 %{config} を設定しサーバーを再起動してください。", + "transcodingEnabled": "Navidromeは現在 %{config} の設定で実行されており、WebUIのトランスコード設定からコマンドを実行できます。\nセキュリティ上の問題から、この設定はトランスコード設定を変更する時のみ有効にすることを推奨します。", + "songsAddedToPlaylist": "プレイリストへ1曲追加しました |||| プレイリストへ%{smart_count}曲追加しました", + "noPlaylistsAvailable": "利用不可", + "delete_user_title": "'%{name}' を削除", + "delete_user_content": "このユーザーとその全てのデータ(プレイリストや設定)を削除してもよろしいですか?", + "notifications_blocked": "ブラウザの設定でこのサイトの通知がブロックされています", + "notifications_not_available": "このブラウザはデスクトップ通知をサポートしていません", + "lastfmLinkSuccess": "Last.fmとリンクしscrobbleが有効になりました", + "lastfmLinkFailure": "Last.fmとリンクできませんでした", + "lastfmUnlinkSuccess": "設定が解除され、Last.fmへのscrobbleは無効になっています", + "lastfmUnlinkFailure": "Last.fmとリンクできませんでした", "openIn": { - "lastfm": "Last.fm で開く", - "musicbrainz": "MusicBrainz で開く" + "lastfm": "Last.fmで開く", + "musicbrainz": "MusicBrainzで開く" }, "lastfmLink": "続きを読む", - "shareOriginalFormat": "オリジナルの形式で共有", - "shareDialogTitle": "%{resource}「%{name}」を共有", + "listenBrainzLinkSuccess": "%{user} へのscrobbling設定に成功しました", + "listenBrainzLinkFailure": "ListenBrainzとのリンクに失敗しました: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainzとのリンクとscrobblingを無効化しました。", + "listenBrainzUnlinkFailure": "ListenBrainzとのリンクを解除できませんでした", + "downloadOriginalFormat": "元のフォーマットでダウンロード", + "shareOriginalFormat": "元のフォーマットで共有", + "shareDialogTitle": "%{resource} '%{name}' を共有", "shareBatchDialogTitle": "1 %{resource} を共有 |||| %{smart_count} %{resource} を共有", - "shareCopyToClipboard": "クリップボードにコピー: Ctrl+C、Enter", - "shareSuccess": "URL をクリップボードにコピーしました: %{url}", - "shareFailure": "URL %{url} をクリップボードにコピーできませんでした", - "downloadDialogTitle": "%{resource}「%{name}」(%{size}) をダウンロード", - "downloadOriginalFormat": "オリジナルの形式でダウンロード" + "shareSuccess": "コピーしました: %{url}", + "shareFailure": "コピーに失敗しました %{url}", + "downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter", + "remove_missing_title": "欠落ファイルを削除", + "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が完全に削除されます。", + "remove_all_missing_title": "全ての欠落ファイルを削除", + "remove_all_missing_content": "データベースから欠落ファイルをすべて削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が永久に削除されます。", + "noSimilarSongsFound": "類似の曲が見つかりませんでした", + "noTopSongsFound": "トップソングが見つかりません" }, "menu": { "library": "ライブラリ", - "librarySelector": { - "allLibraries": "すべてのライブラリ (%{count})", - "multipleLibraries": "%{total} 件のライブラリのうち %{selected} 件", - "selectLibraries": "ライブラリを選択", - "none": "無し" - }, "settings": "設定", "version": "バージョン", "theme": "テーマ", @@ -626,48 +523,53 @@ "options": { "theme": "テーマ", "language": "言語", - "defaultView": "デフォルトの画面", + "defaultView": "デフォルト画面", "desktop_notifications": "デスクトップ通知", - "lastfmNotConfigured": "Last.fm API キーが設定されていません。", - "lastfmScrobbling": "Last.fm に Scrobble", - "listenBrainzScrobbling": "ListenBrainz に Scrobble", - "replaygain": "リプレイゲインモード", - "preAmp": "リプレイゲインプリアンプ (dB)", + "lastfmScrobbling": "Last.fmへscrobbleする", + "listenBrainzScrobbling": "ListenBrainzへscrobble", + "replaygain": "ReplayGainモード", + "preAmp": "プリアンプ", "gain": { "none": "無効", - "album": "アルバムゲインを使用", - "track": "トラックゲインを使用" - } + "album": "アルバムゲインを使う", + "track": "トラックゲインを使う" + }, + "lastfmNotConfigured": "Last.fmのAPIキーが設定されていません" } }, "albumList": "アルバム", + "about": "詳細", "playlists": "プレイリスト", - "onlyFavourites": "お気に入りのみ表示", "sharedPlaylists": "共有プレイリスト", - "about": "概要" + "librarySelector": { + "allLibraries": "全てのライブラリ( %{count} )", + "multipleLibraries": "%{selected} 個 / %{total} 個のライブラリ", + "selectLibraries": "ライブラリを選択", + "none": "無し" + } }, "player": { - "playListsText": "再生キュー", + "playListsText": "再生リスト", "openText": "開く", "closeText": "閉じる", "notContentText": "音楽がありません", "clickToPlayText": "クリックして再生", - "clickToPauseText": "クリックして一時停止", + "clickToPauseText": "一時停止", "nextTrackText": "次の曲", "previousTrackText": "前の曲", - "reloadText": "再読み込み", + "reloadText": "更新", "volumeText": "音量", - "toggleLyricText": "歌詞の表示 / 非表示", + "toggleLyricText": "歌詞を切り替え", "toggleMiniModeText": "最小化", - "destroyText": "破棄", + "destroyText": "削除", "downloadText": "ダウンロード", - "removeAudioListsText": "オーディオリストを削除", - "clickToDeleteText": "クリックして %{name} を削除", + "removeAudioListsText": "リストを空にする", + "clickToDeleteText": "クリックして%{name}を削除", "emptyLyricText": "歌詞がありません", "playModeText": { - "order": "順番に再生", - "orderLoop": "繰り返し再生", - "singleLoop": "1 曲のみ繰り返し再生", + "order": "順番に", + "orderLoop": "リピート", + "singleLoop": "一曲リピート", "shufflePlay": "シャッフル" } }, @@ -676,14 +578,14 @@ "homepage": "ホームページ", "source": "ソースコード", "featureRequests": "機能リクエスト", - "lastInsightsCollection": "最終データ収集日時", + "lastInsightsCollection": "最後のデータ収集", "insights": { "disabled": "無効", "waiting": "待機中" } }, "tabs": { - "about": "概要", + "about": "詳細", "config": "設定" }, "config": { @@ -691,43 +593,42 @@ "environmentVariable": "環境変数", "currentValue": "現在値", "configurationFile": "設定ファイル", - "exportToml": "設定をエクスポート (TOML)", - "downloadToml": "設定をダウンロード (TOML)", - "exportSuccess": "設定が TOML 形式でクリップボードにエクスポートされました", + "exportToml": "設定をエクスポート(TOML)", + "exportSuccess": "設定をTOML形式でクリップボードへエクスポートしました", "exportFailed": "設定のコピーに失敗しました", - "devFlagsHeader": "開発用フラグ(変更または削除される可能性があります)", + "devFlagsHeader": "開発フラグ(変更・削除の可能性あり)", "devFlagsComment": "これらは実験的な設定であり、将来のバージョンで削除される可能性があります" } }, "activity": { - "title": "アクティビティ", - "totalScanned": "スキャンされたフォルダーの総数", + "title": "活動", + "totalScanned": "スキャン済みフォルダー", "quickScan": "クイック", "fullScan": "フル", - "selectiveScan": "選択的", "serverUptime": "サーバー稼働時間", - "serverDown": "オフライン", + "serverDown": "サーバーオフライン", "scanType": "最終スキャン", "status": "スキャンエラー", - "elapsedTime": "経過時間" + "elapsedTime": "経過時間", + "selectiveScan": "選択的スキャン" + }, + "help": { + "title": "ホットキー", + "hotkeys": { + "show_help": "このヘルプを表示", + "toggle_menu": "サイドバーの表示/非表示", + "toggle_play": "再生/停止", + "prev_song": "前の曲", + "next_song": "次の曲", + "vol_up": "音量を上げる", + "vol_down": "音量を下げる", + "toggle_love": "星の付け外し", + "current_song": "現在の曲へ移動" + } }, "nowPlaying": { "title": "再生中", - "empty": "再生中の曲はありません", - "minutesAgo": "%{smart_count} 分前" - }, - "help": { - "title": "Navidrome ホットキー", - "hotkeys": { - "show_help": "ヘルプを表示", - "toggle_menu": "サイドバーの表示 / 非表示", - "toggle_play": "再生 / 一時停止", - "prev_song": "前の曲", - "next_song": "次の曲", - "current_song": "再生中の曲へ移動", - "vol_up": "音量を上げる", - "vol_down": "音量を下げる", - "toggle_love": "この曲をお気に入り登録 / 登録解除" - } + "empty": "何も再生されていません", + "minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前" } -} +} \ No newline at end of file diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index cb276b8f4..3f638c13c 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -38,9 +38,7 @@ "missing": "Ontbrekend", "libraryName": "Bibliotheek", "composer": "Componist", - "disc": "Schijf %{discNumber}", - "albumGain": "Album gain", - "trackGain": "Nummer gain" + "disc": "Schijf %{discNumber}" }, "actions": { "addToQueue": "Voeg toe aan wachtrij", @@ -93,8 +91,7 @@ "addToPlaylist": "Toevoegen aan afspeellijst", "download": "Downloaden", "info": "Meer info", - "share": "Delen", - "refresh": "Metadata verversen" + "share": "Delen" }, "lists": { "all": "Alle", @@ -156,13 +153,11 @@ "newPassword": "Nieuw wachtwoord", "token": "Token", "lastAccessAt": "Meest recente toegang", - "libraries": "Bibliotheken", - "scrobbleFilter": "Scrobble filter" + "libraries": "Bibliotheken" }, "helperTexts": { "name": "Naamswijziging wordt pas zichtbaar bij de volgende login", - "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken", - "scrobbleFilter": "Nummers binnen deze slimme afspeellijst regels worden niet gestuurd naar Last.fm, ListenBrainz of scrobbler plugins. Gebruikt dezelfde JSON syntaxis en gedrag als slimme afspeellijsten. Voorbeeld: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leeglaten om alles te scrobblen. Lokale afspeelaantallen vallen hierbuiten." + "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken" }, "notifications": { "created": "Aangemaakt door gebruiker", @@ -176,8 +171,7 @@ "adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken" }, "validation": { - "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers", - "invalidScrobbleFilter": "Moeten geldige slimme afspeellijst regels zijn. Geen ondersteuning voor Limit, Offset en Refresh Delay" + "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers" } }, "player": { @@ -200,9 +194,6 @@ "targetFormat": "Doelformaat", "defaultBitRate": "Standaard bitrate", "command": "Commando" - }, - "choices": { - "noDefaultBitRate": "Geen" } }, "playlist": { @@ -217,8 +208,7 @@ "songCount": "Nummers", "comment": "Commentaar", "sync": "Auto-importeren", - "path": "Importeer vanuit", - "starred": "Favoriet" + "path": "Importeer vanuit" }, "actions": { "selectPlaylist": "Selecteer een afspeellijst:", @@ -411,8 +401,7 @@ "requiredHosts": "Benodigde hosts", "configValidationError": "Configuratiecheck mislukt", "schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.", - "allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen.", - "idHelp": "De plugin ID, afgeleid van zijn bestandsnaam. Gebruik dit als gerefereerd wordt aan deze plugin in de configuratie opties, zoals Agenten." + "allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen." }, "placeholders": { "configKey": "Sleutel", @@ -608,11 +597,7 @@ "coverUploaded": "Albumhoes bijgewerkt", "coverRemoved": "Albumhoes verwijderd", "coverUploadError": "Fout bij het toevoegen albumhoes", - "coverRemoveError": "Fout bij verwijderen albumhoes", - "metadataRefreshStarted": "Metadata verversen op de achtergrond", - "quickConnectApproved": "", - "quickConnectInvalidCode": "", - "quickConnectError": "" + "coverRemoveError": "Fout bij verwijderen albumhoes" }, "menu": { "library": "Bibliotheek", @@ -647,15 +632,6 @@ "multipleLibraries": "%{selected} van %{total} bibliotheken", "selectLibraries": "Selecteer bibliotheken", "none": "Geen" - }, - "onlyFavourites": "Toon alleen favorieten", - "quickConnect": { - "name": "", - "code": "", - "help": "", - "confirm": "", - "continue": "", - "approve": "" } }, "player": { diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json index ff697d64c..6229798e9 100644 --- a/resources/i18n/pl.json +++ b/resources/i18n/pl.json @@ -37,10 +37,7 @@ "sampleRate": "Częstotliwość próbkowania", "missing": "Brak", "libraryName": "Biblioteka", - "composer": "Kompozytor", - "disc": "Dysk %{discNumber}", - "albumGain": "Wzmocnienie albumu", - "trackGain": "Wzmocnienie utworu" + "composer": "Kompozytor" }, "actions": { "addToQueue": "Odtwarzaj Później", @@ -93,8 +90,7 @@ "addToPlaylist": "Dodaj do Playlisty", "download": "Pobierz", "info": "Zdobądź Informacje", - "share": "Udostępnij", - "refresh": "Odśwież Metadane" + "share": "Udostępnij" }, "lists": { "all": "Wszystkie", @@ -156,13 +152,11 @@ "newPassword": "Nowe hasło", "token": "Token", "lastAccessAt": "Ostatnia Aktywność", - "libraries": "Biblioteki", - "scrobbleFilter": "Filtr scrobblowania" + "libraries": "Biblioteki" }, "helperTexts": { "name": "Zmiana nazwy będzie widoczna przy następnym logowaniu", - "libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki", - "scrobbleFilter": "Utwory spełniające kryteria tych inteligentnych list odtwarzania nie są wysyłane do serwisów Last.fm, ListenBrainz ani do wtyczek typu scrobbler. Wykorzystywana jest tu ta sama składnia JSON i zasada działania, co w przypadku inteligentnych list odtwarzania. Przykład: {\"all\":[{\"lt\":{\"rating\":4}}]}. Pozostawienie pola pustego spowoduje scrobblowanie wszystkich utworów. Nie wpływa to na lokalne liczniki odtworzeń." + "libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki" }, "notifications": { "created": "Dodano użytkownika", @@ -176,8 +170,7 @@ "adminAutoLibraries": "Administratorzy automatycznie mają dostęp do wszystkich bibliotek" }, "validation": { - "librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika", - "invalidScrobbleFilter": "Reguły inteligentnej listy odtwarzania muszą być poprawne. Parametry limitu, przesunięcia oraz opóźnienia odświeżania nie są obsługiwane." + "librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika" } }, "player": { @@ -200,9 +193,6 @@ "targetFormat": "Format Docelowy", "defaultBitRate": "Domyślny Bit Rate", "command": "Komenda" - }, - "choices": { - "noDefaultBitRate": "" } }, "playlist": { @@ -217,8 +207,7 @@ "songCount": "Liczba utworów", "comment": "Komentarz", "sync": "Import automatyczny", - "path": "Zaimportuj z", - "starred": "Ulubione" + "path": "Zaimportuj z" }, "actions": { "selectPlaylist": "Wybierz playlistę:", @@ -364,8 +353,7 @@ "allUsers": "Zezwalaj wszystkim użytkownikom", "selectedUsers": "Wybrani użytkownicy", "allLibraries": "Zezwalaj dla wszystkich bibliotek", - "selectedLibraries": "Wybrane biblioteki", - "allowWriteAccess": "Zezwól na zapis" + "selectedLibraries": "Wybrane biblioteki" }, "sections": { "status": "Status", @@ -410,9 +398,7 @@ "librariesRequired": "Wtyczka wymaga dostępu do informacji o bibliotece. Wybierz, dla której biblioteki zezwolić dostęp, lub włącz 'Zezwalaj dla wszystkich bibliotek'.", "requiredHosts": "Wymagane hosty", "configValidationError": "Weryfikacja konfiguracji nie powiodła się:", - "schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy.", - "allowWriteAccessHelp": "Po włączeniu wtyczka może modyfikować pliki w katalogach bibliotek. Domyślnie wtyczki mają dostęp tylko do odczytu.", - "idHelp": "" + "schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy." }, "placeholders": { "configKey": "klucz", @@ -602,14 +588,7 @@ "remove_all_missing_content": "Czy chcesz usunąć wszystkie brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszelkich odniesień do tych plików, takich jak liczba odtworzeń, czy oceny.", "noSimilarSongsFound": "Brak podobnych utworów", "noTopSongsFound": "Brak najlepszych utworów", - "startingInstantMix": "Ładowanie Natychmiastowego Miksu...", - "uploadCover": "Prześlij Okładkę", - "removeCover": "Usuń Okładkę", - "coverUploaded": "Okładka zaktualizowana", - "coverRemoved": "Okładka usunięta", - "coverUploadError": "Błąd przesyłania okładki", - "coverRemoveError": "Błąd usuwania okładki", - "metadataRefreshStarted": "Odświeżanie metadanych w tle" + "startingInstantMix": "Ładowanie Natychmiastowego Miksu..." }, "menu": { "library": "Biblioteka", @@ -644,8 +623,7 @@ "multipleLibraries": "%{selected} z %{total} Bibliotek", "selectLibraries": "Wybierz Biblioteki", "none": "Żadna" - }, - "onlyFavourites": "Pokaż tylko ulubione" + } }, "player": { "playListsText": "Kolejka Odtwarzania", @@ -696,8 +674,7 @@ "exportSuccess": "Konfiguracja wyeksportowana do schowka w formacie TOML", "exportFailed": "Błąd kopiowania konfiguracji", "devFlagsHeader": "Flagi Rozwojowe (mogą ulec zmianie/usunięciu)", - "devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach", - "downloadToml": "Konfiguracja Pobierania (TOML)" + "devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach" } }, "activity": { diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 238919259..d9f29f5d4 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 do Álbum", + "albumArtist": "Artista", "duration": "Duração", "trackNumber": "#", "playCount": "Execuções", @@ -35,12 +35,12 @@ "rawTags": "Tags originais", "bitDepth": "Profundidade de bits", "sampleRate": "Taxa de amostragem", + "albumGain": "Ganho do álbum", + "trackGain": "Ganho da faixa", "missing": "Ausente", "libraryName": "Biblioteca", "composer": "Compositor", - "disc": "Disco %{discNumber}", - "albumGain": "Ganho do álbum", - "trackGain": "Ganho da faixa" + "disc": "Disco %{discNumber}" }, "actions": { "addToQueue": "Adicionar à fila", @@ -57,7 +57,7 @@ "album": { "name": "Álbum |||| Álbuns", "fields": { - "albumArtist": "Artista do Álbum", + "albumArtist": "Artista", "artist": "Artista", "duration": "Duração", "songCount": "Músicas", @@ -93,8 +93,7 @@ "addToPlaylist": "Adicionar à playlist", "download": "Baixar", "info": "Detalhes", - "share": "Compartilhar", - "refresh": "Atualizar Metadados" + "share": "Compartilhar" }, "lists": { "all": "Todos", @@ -156,13 +155,11 @@ "newPassword": "Nova Senha", "token": "Token", "lastAccessAt": "Últ. Acesso", - "libraries": "Bibliotecas", - "scrobbleFilter": "Filtro de scrobble" + "libraries": "Bibliotecas" }, "helperTexts": { "name": "Alterações no seu nome só serão refletidas no próximo login", - "libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão", - "scrobbleFilter": "Músicas que correspondem a estas regras de playlist inteligente não são enviadas ao Last.fm, ListenBrainz ou plugins de scrobble. Usa a mesma sintaxe JSON e o mesmo comportamento das playlists inteligentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixe vazio para enviar scrobbles de todas as músicas. As contagens de reprodução locais não são afetadas." + "libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão" }, "notifications": { "created": "Novo usuário criado", @@ -176,13 +173,11 @@ "adminAutoLibraries": "Usuários administradores têm acesso automático a todas as bibliotecas" }, "validation": { - "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores", - "invalidScrobbleFilter": "Devem ser regras válidas de playlist inteligente. Limite, deslocamento e intervalo de atualização não são suportados." + "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores" } }, "player": { "name": "Tocador |||| Tocadores", - "menuName": "Tocadores e chaves de API", "fields": { "name": "Nome", "transcodingId": "Conversão", @@ -191,29 +186,7 @@ "userName": "Usuário", "lastSeen": "Últ. acesso", "reportRealPath": "Use paths reais", - "scrobbleEnabled": "Enviar scrobbles para serviços externos", - "hasApiKey": "Chave de API" - }, - "actions": { - "generateApiKey": "Gerar chave de API", - "regenerateApiKey": "Gerar nova", - "revokeApiKey": "Revogar", - "copyApiKey": "Copiar" - }, - "message": { - "apiKeyActive": "Este tocador tem uma chave de API. Use-a no seu app como chave de API, ou como senha se o app não usar autenticação por token.", - "apiKeyNone": "Sem chave de API. Gere uma para conectar um app a este tocador.", - "apiKeyNoneOther": "Sem chave de API.", - "apiKeyPending": "Copie esta chave agora. Ela será salva quando você clicar em Salvar e não será exibida novamente.", - "apiKeyRevokePending": "A chave de API será removida quando você salvar.", - "deleteWithKeyTitle": "Excluir tocador", - "deleteWithKeyContent": "Este tocador tem uma chave de API. Os apps que a usam vão parar de funcionar." - }, - "notifications": { - "apiKeyCopied": "Chave de API copiada para o clipboard" - }, - "validation": { - "apiKeyFormat": "Formato de chave de API inválido" + "scrobbleEnabled": "Enviar scrobbles para serviços externos" } }, "transcoding": { @@ -223,9 +196,6 @@ "targetFormat": "Formato", "defaultBitRate": "Bitrate padrão", "command": "Comando" - }, - "choices": { - "noDefaultBitRate": "Nenhum" } }, "playlist": { @@ -240,8 +210,7 @@ "songCount": "Músicas", "comment": "Comentário", "sync": "Auto-importar", - "path": "Importar de", - "starred": "Favorita" + "path": "Importar de" }, "actions": { "selectPlaylist": "Selecione a playlist:", @@ -328,22 +297,11 @@ "totalDuration": "Duração", "defaultNewUsers": "Padrão para Novos Usuários", "createdAt": "Data de Criação", - "updatedAt": "Últ. Atualização", - "pidAlbum": "Agrupamento de álbuns", - "pidTrack": "Identificação das faixas" + "updatedAt": "Últ. Atualização" }, "sections": { "basic": "Informações Básicas", - "statistics": "Estatísticas", - "pid": "IDs Persistentes" - }, - "pid": { - "global": "Usar configuração global (%{value})", - "folder": "Pasta (um álbum por pasta)", - "custom": "Personalizado", - "spec": "Especificação do PID", - "help": "Tags e atributos que identificam um item. Consulte a sintaxe na documentação:", - "docs": "IDs Persistentes" + "statistics": "Estatísticas" }, "actions": { "scan": "Scanear Biblioteca", @@ -373,9 +331,7 @@ "messages": { "deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.", "scanInProgress": "Scan em progresso...", - "noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário", - "pidChangeTitle": "Alterar os IDs persistentes?", - "pidChangeConfirm": "Ao salvar, os álbuns desta biblioteca serão reagrupados e as faixas serão identificadas novamente. Um scan completo da biblioteca começará imediatamente. As marcações como favoritas, as classificações e as contagens de reprodução das faixas serão mantidas. Os favoritos e as classificações dos álbuns serão transferidos para os novos álbuns quando um álbum antigo corresponder a um novo." + "noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário" } }, "plugin": { @@ -447,8 +403,7 @@ "requiredHosts": "Hosts necessários", "configValidationError": "Falha na validação da configuração:", "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.", - "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.", - "idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents." + "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura." }, "placeholders": { "configKey": "chave", @@ -644,11 +599,7 @@ "coverUploaded": "Capa atualizada", "coverRemoved": "Capa removida", "coverUploadError": "Erro ao enviar capa", - "coverRemoveError": "Erro ao remover capa", - "metadataRefreshStarted": "Atualizando metadados em segundo plano", - "quickConnectApproved": "%{app} em %{device} está conectado agora", - "quickConnectInvalidCode": "Código inválido ou expirado", - "quickConnectError": "Não foi possível aprovar o código" + "coverRemoveError": "Erro ao remover capa" }, "menu": { "library": "Biblioteca", @@ -683,15 +634,6 @@ "multipleLibraries": "%{selected} de %{total} Bibliotecas", "selectLibraries": "Selecionar Bibliotecas", "none": "Nenhuma" - }, - "onlyFavourites": "Somente favoritas", - "quickConnect": { - "name": "Conexão Rápida", - "code": "Código", - "help": "Digite o código exibido por um aplicativo Jellyfin para conectá-lo à sua conta", - "confirm": "Conectar %{app} %{version} em %{device} à sua conta?", - "continue": "Continuar", - "approve": "Aprovar" } }, "player": { diff --git a/resources/i18n/sk.json b/resources/i18n/sk.json index f294d1602..af5afade7 100644 --- a/resources/i18n/sk.json +++ b/resources/i18n/sk.json @@ -2,7 +2,7 @@ "languageName": "Slovenčina", "resources": { "song": { - "name": "Skladba |||| Skladby", + "name": "Skladba |||| Skladieb", "fields": { "albumArtist": "Interpret albumu", "duration": "Dĺžka", @@ -10,14 +10,20 @@ "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", @@ -25,7 +31,6 @@ "quality": "Kvalita", "bpm": "BPM", "playDate": "Naposledy prehraná skladba", - "channels": "Kanály", "createdAt": "Pridané", "grouping": "Zoskupovanie", "mood": "Nálada", @@ -33,24 +38,17 @@ "tags": "Ďalšie značky", "mappedTags": "Mapované značky", "rawTags": "Nespracované značky", - "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" + "missing": "Chýbajúce" }, "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" } }, @@ -62,38 +60,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", - "updatedAt": "Aktualizované", - "comment": "Komentár", - "rating": "Hodnotenie", - "createdAt": "Pridané", - "size": "Veľkosť", + "date": "Dátum záznamu", "originalDate": "Pôvodné", "releaseDate": "Vydané", "releases": "Vydanie |||| Vydania", "released": "Vydané", + "updatedAt": "Aktualizované", + "comment": "Komentár", + "rating": "Hodnotenie", + "createdAt": "Pridané", "recordLabel": "Štítok", "catalogNum": "Katalógové číslo", "releaseType": "Typ vydania", "grouping": "Zoskupovanie", "media": "Médiá", "mood": "Nálada", - "date": "Dátum záznamu", - "missing": "Chýbajúce", - "libraryName": "Knižnica" + "missing": "Chýbajúce" }, "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", - "share": "Zdieľať" + "info": "Získať informácie" }, "lists": { "all": "Všetko", @@ -111,10 +109,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" }, @@ -135,9 +133,9 @@ "maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti" }, "actions": { + "topSongs": "Najpopulárnejšie skladby", "shuffle": "Zamiešať", - "radio": "Rádio", - "topSongs": "Najpopulárnejšie skladby" + "radio": "Rádio" } }, "user": { @@ -146,6 +144,7 @@ "userName": "Používateľské meno", "isAdmin": "Správca", "lastLoginAt": "Naposledy prihlásený", + "lastAccessAt": "Posledný Prístup", "updatedAt": "Upravený", "name": "Meno", "password": "Heslo", @@ -154,7 +153,6 @@ "currentPassword": "Súčastné heslo", "newPassword": "Nové heslo", "token": "Token", - "lastAccessAt": "Posledný Prístup", "libraries": "Knižnice" }, "helperTexts": { @@ -166,14 +164,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": { @@ -216,9 +214,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" @@ -249,6 +247,7 @@ "username": "Zdieľané", "url": "URL", "description": "Popis", + "downloadable": "Povoliť sťahovanie?", "contents": "Obsah", "expiresAt": "Vyprší", "lastVisitedAt": "Naposledy navštívené", @@ -256,17 +255,19 @@ "format": "Formát", "maxBitRate": "Max. Bit Rate", "updatedAt": "Nahrané", - "createdAt": "Vytvorené", - "downloadable": "Povoliť sťahovanie?" - } + "createdAt": "Vytvorené" + }, + "notifications": {}, + "actions": {} }, "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ť", - "updatedAt": "Zmizol dňa", - "libraryName": "Knižnica" + "libraryName": "Knižnica", + "updatedAt": "Zmizol dňa" }, "actions": { "remove": "Odstrániť", @@ -274,8 +275,7 @@ }, "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", - "manageUsers": "Spravovať prístup používateľov", - "viewDetails": "Zobraziť detaily", "quickScan": "Rýchly sken", - "fullScan": "Úplný sken" + "fullScan": "Úplný sken", + "manageUsers": "Spravovať prístup používateľov", + "viewDetails": "Zobraziť detaily" }, "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" + "scanError": "Chyba pri spustení skenu. Skontrolujte logy", + "scanCompleted": "Skenovanie knižnice dokončené" }, "validation": { "nameRequired": "Názov knižnice je povinný", @@ -391,6 +391,8 @@ }, "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.", @@ -400,10 +402,8 @@ "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'.", - "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." + "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" }, "placeholders": { "configKey": "kľúč", @@ -446,6 +446,7 @@ "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ť", @@ -469,7 +470,6 @@ "close_menu": "Zavrieť ponuku", "unselect": "Zrušiť výber", "skip": "Preskočiť", - "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "Zdieľať", "download": "Stiahnuť" }, @@ -557,52 +557,58 @@ } }, "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})", - "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" + "downloadOriginalFormat": "Stiahnuť v pôvodnom formáte" }, "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", @@ -613,6 +619,7 @@ "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", @@ -621,20 +628,13 @@ "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", - "librarySelector": { - "allLibraries": "Všetky knižnice (%{count})", - "multipleLibraries": "%{selected} z %{total} knižníc", - "selectLibraries": "Vyberte knižnice", - "none": "Žiadne" - } + "about": "O Navidrome" }, "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", - "downloadToml": "Stiahnuť konfiguráciu (TOML)" + "devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách" } }, "activity": { @@ -694,12 +694,17 @@ "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", - "selectiveScan": "Selektívne" + "elapsedTime": "Uplynutý čas" + }, + "nowPlaying": { + "title": "Práve hrá", + "empty": "Nič sa neprehráva", + "minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami" }, "help": { "title": "Klávesové skratky Navidrome", @@ -709,15 +714,10 @@ "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", - "current_song": "Prejsť na aktuálnu skladbu" + "toggle_love": "Pridať túto skladbu do obľúbených" } - }, - "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 cf0fc5d2f..1cf7e39e7 100644 --- a/resources/i18n/sr.json +++ b/resources/i18n/sr.json @@ -4,54 +4,45 @@ "song": { "name": "Песма |||| Песме", "fields": { - "albumArtist": "Уметник албума", - "duration": "Трајање", - "trackNumber": "#", - "playCount": "Пуштано", - "title": "Наслов", - "artist": "Уметник", - "composer": "Композитор", "album": "Албум", - "path": "Путања фајла", - "libraryName": "Библиотека", - "genre": "Жанр", - "compilation": "Компилација", - "year": "Година", - "size": "Величина фајла", - "updatedAt": "Ажурирано", + "albumArtist": "Уметник албума", + "artist": "Уметник", + "bitDepth": "Битова", "bitRate": "Битски проток", - "bitDepth": "Битска дубина", - "sampleRate": "Учестаност узорковања", - "albumGain": "Појачање албума", - "trackGain": "Појачање нумере", - "channels": "Канали", - "disc": "Диск %{discNumber}", - "discSubtitle": "Поднаслов диска", - "starred": "Омиљено", - "comment": "Коментар", - "rating": "Рејтинг", - "quality": "Квалитет", "bpm": "BPM", - "playDate": "Последње пуштано", + "channels": "Канала", + "comment": "Коментар", + "compilation": "Компилација", "createdAt": "Датум додавања", + "discSubtitle": "Поднаслов диска", + "duration": "Трајање", + "genre": "Жанр", "grouping": "Груписање", + "mappedTags": "Мапиране ознаке", "mood": "Расположење", "participants": "Додатни учесници", - "tags": "Додатне ознаке", - "mappedTags": "Мапиране ознаке", + "path": "Путања фајла", + "playCount": "Пуштано", + "playDate": "Последње пуштано", + "quality": "Квалитет", + "rating": "Рејтинг", "rawTags": "Сирове ознаке", - "missing": "Недостаје" + "size": "Величина фајла", + "starred": "Омиљено", + "tags": "Додатне ознаке", + "title": "Наслов", + "trackNumber": "#", + "updatedAt": "Ажурирано", + "year": "Година" }, "actions": { - "addToQueue": "Пусти касније", - "playNow": "Пусти одмах", "addToPlaylist": "Додај у плејлисту", - "showInPlaylist": "Прикажи у плејлисти", - "shuffleAll": "Измешај све", + "addToQueue": "Пусти касније", "download": "Преузми", - "playNext": "Пусти наредно", "info": "Прикажи инфо", - "instantMix": "Инстант микс" + "playNext": "Пусти наредно", + "playNow": "Пусти одмах", + "shuffleAll": "Измешај све" } }, "album": { @@ -59,48 +50,46 @@ "fields": { "albumArtist": "Уметник албума", "artist": "Уметник", - "duration": "Трајање", - "songCount": "Песме", - "playCount": "Пуштано", - "size": "Величина", - "name": "Назив", - "libraryName": "Библиотека", - "genre": "Жанр", - "compilation": "Компилација", - "year": "Година", - "date": "Датум снимања", - "originalDate": "Оригинално", - "releaseDate": "Објављено", - "releases": "Издање|||| Издања", - "released": "Објављено", - "updatedAt": "Ажурирано", - "comment": "Коментар", - "rating": "Рејтинг", - "createdAt": "Датум додавања", - "recordLabel": "Издавачка кућа", "catalogNum": "Каталошки број", - "releaseType": "Тип", + "comment": "Коментар", + "compilation": "Компилација", + "createdAt": "Датум додавања", + "date": "Датум снимања", + "duration": "Трајање", + "genre": "Жанр", "grouping": "Груписање", "media": "Медијум", "mood": "Расположење", - "missing": "Недостаје" + "name": "Назив", + "originalDate": "Оригинално", + "playCount": "Пуштано", + "rating": "Рејтинг", + "recordLabel": "Издавачка кућа", + "releaseDate": "Објављено", + "releaseType": "Тип", + "released": "Објављено", + "releases": "Издање|||| Издања", + "size": "Величина", + "songCount": "Песме", + "updatedAt": "Ажурирано", + "year": "Година" }, "actions": { + "addToPlaylist": "Додај у плејлисту", + "addToQueue": "Пусти касније", + "download": "Преузми", + "info": "Прикажи инфо", "playAll": "Пусти", "playNext": "Пусти наредно", - "addToQueue": "Пусти касније", "share": "Дели", - "shuffle": "Измешај", - "addToPlaylist": "Додај у плејлисту", - "download": "Преузми", - "info": "Прикажи инфо" + "shuffle": "Измешај" }, "lists": { "all": "Све", + "mostPlayed": "Најчешће пуштано", "random": "Насумично", "recentlyAdded": "Додато недавно", "recentlyPlayed": "Пуштано недавно", - "mostPlayed": "Најчешће пуштано", "starred": "Омиљено", "topRated": "Најбоље рангирано" } @@ -108,136 +97,116 @@ "artist": { "name": "Уметник |||| Уметници", "fields": { - "name": "Назив", "albumCount": "Број албума", - "songCount": "Број песама", - "size": "Величина", + "genre": "Жанр", + "name": "Назив", "playCount": "Пуштано", "rating": "Рејтинг", - "genre": "Жанр", "role": "Улога", - "missing": "Недостаје" + "size": "Величина", + "songCount": "Број песама" }, "roles": { "albumartist": "Уметник албума |||| Уметници албума", + "arranger": "Аранжер |||| Аранжери", "artist": "Уметник |||| Уметници", "composer": "Композитор |||| Композитори", "conductor": "Диригент |||| Диригенти", - "lyricist": "Текстописац |||| Текстописци", - "arranger": "Аранжер |||| Аранжери", - "producer": "Продуцент |||| Продуценти", "director": "Режисер |||| Режисери", - "engineer": "Инжењер |||| Инжењери", - "mixer": "Миксер |||| Миксери", - "remixer": "Ремиксер |||| Ремиксери", "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер", + "engineer": "Инжењер |||| Инжењери", + "lyricist": "Текстописац |||| Текстописци", + "mixer": "Миксер |||| Миксери", "performer": "Извођач |||| Извођачи", - "maincredit": "Уметник албума или уметник |||| Уметници албума или уметници" - }, - "actions": { - "topSongs": "Најбоље песме", - "shuffle": "Измешај", - "radio": "Радио" + "producer": "Продуцент |||| Продуценти", + "remixer": "Ремиксер |||| Ремиксери" } }, "user": { "name": "Корисник |||| Корисници", "fields": { - "userName": "Корисничко име", - "isAdmin": "Да ли је Админ", - "lastLoginAt": "Последња пријава", - "lastAccessAt": "Последњи приступ", - "updatedAt": "Ажурирано", - "name": "Назив", - "password": "Лозинка", - "createdAt": "Креирана", "changePassword": "Измени лозинку?", + "createdAt": "Креирана", "currentPassword": "Текућа лозинка", + "isAdmin": "Да ли је Админ", + "lastAccessAt": "Последњи приступ", + "lastLoginAt": "Последња пријава", + "name": "Назив", "newPassword": "Нова лозинка", + "password": "Лозинка", "token": "Жетон", - "libraries": "Библиотеке" + "updatedAt": "Ажурирано", + "userName": "Корисничко име" }, "helperTexts": { - "name": "Измене вашег имена ће постати видљиве након следеће пријаве", - "libraries": "Изаберите одређене библиотеке за овог корисника, или оставите празно да се користе подразумеване библиотеке" + "name": "Измене вашег имена ће постати видљиве након следеће пријаве" }, "notifications": { "created": "Корисник креиран", - "updated": "Корисник ажуриран", - "deleted": "Корисник обрисан" - }, - "validation": { - "librariesRequired": "Барем једна библиотека мора да буде изабрана за кориснике који нису администратори" + "deleted": "Корисник обрисан", + "updated": "Корисник ажуриран" }, "message": { - "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.", "clickHereForToken": "Кликните овде да преузмете свој жетон", - "selectAllLibraries": "Изабери све библиотеке", - "adminAutoLibraries": "Администратори аутоматски имају приступ свим библиотекама" + "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон." } }, "player": { "name": "Плејер |||| Плејери", "fields": { - "name": "Назив", - "transcodingId": "Транскодирање", - "maxBitRate": "Макс. битски проток", "client": "Клијент", - "userName": "Корисничко име", "lastSeen": "Последњи пут виђен", + "maxBitRate": "Макс. битски проток", + "name": "Назив", "reportRealPath": "Пријављуј реалну путању", - "scrobbleEnabled": "Шаљи скроблове на спољне сервисе" + "scrobbleEnabled": "Шаљи скроблове на спољне сервисе", + "transcodingId": "Транскодирање", + "userName": "Корисничко име" } }, "transcoding": { "name": "Транскодирање |||| Транскодирања", "fields": { - "name": "Назив", - "targetFormat": "Циљни формат", + "command": "Команда", "defaultBitRate": "Подразумевани битски проток", - "command": "Команда" + "name": "Назив", + "targetFormat": "Циљни формат" } }, "playlist": { "name": "Плејлиста |||| Плејлисте", "fields": { - "name": "Назив", - "duration": "Трајање", - "ownerName": "Власник", - "public": "Јавна", - "updatedAt": "Ажурирано", - "createdAt": "Креирана", - "songCount": "Песме", "comment": "Коментар", + "createdAt": "Креирана", + "duration": "Трајање", + "name": "Назив", + "ownerName": "Власник", + "path": "Увоз из", + "public": "Јавна", + "songCount": "Песме", "sync": "Ауто-увоз", - "path": "Увоз из" + "updatedAt": "Ажурирано" }, "actions": { - "selectPlaylist": "Изабери плејлисту", "addNewPlaylist": "Креирај „%{name}”", "export": "Извези", - "saveQueue": "Сачувај ред у плејлисту", - "makePublic": "Учини јавном", "makePrivate": "Учини приватном", - "searchOrCreate": "Претражите плејлисте или унесите назив за нову…", - "pressEnterToCreate": "Притисните Ентер да креирате нову плејлисту", - "removeFromSelection": "Уклони из избора" + "makePublic": "Учини јавном", + "selectPlaylist": "Изабери плејлисту" }, "message": { "duplicate_song": "Додај дуплиране песме", - "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?", - "noPlaylistsFound": "Нема пронађених плејлиста", - "noPlaylists": "Нема доступних плејлиста" + "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?" } }, "radio": { - "name": "Радио |||| Радио-станице", + "name": "Радио |||| Радији", "fields": { + "createdAt": "Креирана", + "homePageUrl": "URL почетне странице", "name": "Назив", "streamUrl": "URL тока", - "homePageUrl": "URL почетне странице", - "updatedAt": "Ажурирано", - "createdAt": "Креирана" + "updatedAt": "Ажурирано" }, "actions": { "playNow": "Пусти одмах" @@ -246,18 +215,18 @@ "share": { "name": "Дељење |||| Дељења", "fields": { - "username": "Поделио", - "url": "URL", + "contents": "Садржај", + "createdAt": "Креирано", "description": "Опис", "downloadable": "Допушта се преузимање?", - "contents": "Садржај", "expiresAt": "Истиче", - "lastVisitedAt": "Последњи пут посећено", - "visitCount": "Број посета", "format": "Формат", + "lastVisitedAt": "Последњи пут посећено", "maxBitRate": "Макс. битски проток", "updatedAt": "Ажурирано", - "createdAt": "Креирано" + "url": "URL", + "username": "Поделио", + "visitCount": "Број посета" }, "notifications": {}, "actions": {} @@ -268,246 +237,111 @@ "fields": { "path": "Путања", "size": "Величина", - "libraryName": "Библиотека", "updatedAt": "Нестао дана" }, "actions": { - "remove": "Уклони", - "remove_all": "Уклони све" + "remove": "Уклони" }, "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": { - "welcome1": "Хвала што сте инсталирали Navidrome!", - "welcome2": "За почетак, креирајте админ корисника", - "confirmPassword": "Потврдите лозинку", - "buttonCreateAdmin": "Креирај админа", "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите", - "user_menu": "Профил", - "username": "Корисничко име", + "buttonCreateAdmin": "Креирај админа", + "confirmPassword": "Потврдите лозинку", + "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите", + "logout": "Одјави се", "password": "Лозинка", "sign_in": "Пријави се", "sign_in_error": "Потврда идентитета није успела, покушајте поново", - "logout": "Одјави се", - "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите" + "user_menu": "Профил", + "username": "Корисничко име", + "welcome1": "Хвала што сте инсталирали Navidrome!", + "welcome2": "За почетак, креирајте админ корисника" }, "validation": { - "invalidChars": "Молимо вас да користите само слова и цифре", - "passwordDoesNotMatch": "Лозинка се не подудара", - "required": "Неопходно", - "minLength": "Мора да буде барем %{min} карактера", - "maxLength": "Мора да буде %{max} карактера или мање", - "minValue": "Мора да буде барем %{min}", - "maxValue": "Мора да буде %{max} или мање", - "number": "Мора да буде број", "email": "Мора да буде исправна и-мејл адреса", + "invalidChars": "Молимо вас да користите само слова и цифре", + "maxLength": "Мора да буде %{max} карактера или мање", + "maxValue": "Мора да буде %{max} или мање", + "minLength": "Мора да буде барем %{min} карактера", + "minValue": "Мора да буде барем %{min}", + "number": "Мора да буде број", "oneOf": "Мора да буде једно од: %{options}", + "passwordDoesNotMatch": "Лозинка се не подудара", "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}", + "required": "Неопходно", "unique": "Мора да буде јединствено", "url": "Мора да буде исправна URL адреса" }, "action": { - "add_filter": "Додај филтер", "add": "Додај", + "add_filter": "Додај филтер", "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_filter": "Уклони овај филтер", "remove": "Уклони", + "remove_filter": "Уклони овај филтер", "save": "Сачувај", "search": "Тражи", + "share": "Дели", "show": "Прикажи", + "skip": "Прескочи", "sort": "Сортирај", "undo": "Поништи", - "expand": "Развиј", - "close": "Затвори", - "open_menu": "Отвори мени", - "close_menu": "Затвори мени", - "unselect": "Уклони избор", - "skip": "Прескочи", - "share": "Дели", - "download": "Преузми" + "unselect": "Уклони избор" }, "boolean": { - "true": "Да", - "false": "Не" + "false": "Не", + "true": "Да" }, "page": { "create": "Креирај %{name}", "dashboard": "Контролна табла", "edit": "%{name} #%{id}", + "empty": "Још увек нема %{name}.", "error": "Нешто је пошло наопако", + "invite": "Желите ли да се дода?", "list": "%{name}", "loading": "Учитава се", "not_found": "Није пронађено", - "show": "%{name} #%{id}", - "empty": "Још увек нема %{name}.", - "invite": "Желите ли да се дода?" + "show": "%{name} #%{id}" }, "input": { "file": { - "upload_several": "Превуците фајлове да се отпреме, или кликните да их изаберете.", - "upload_single": "Превуците фајл да се отпреми, или кликните да га изаберете." + "upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.", + "upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете." }, "image": { - "upload_several": "Превуците слике да се отпреме, или кликните да их изаберете.", - "upload_single": "Превуците слику да се отпреми, или кликните да је изаберете." + "upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.", + "upload_single": "Упустите слику да се отпреми, или кликните да је изаберете." + }, + "password": { + "toggle_hidden": "Прикажи лозинку", + "toggle_visible": "Сакриј лозинку" }, "references": { "all_missing": "Не могу да се нађу подаци референци.", "many_missing": "Изгледа да барем једна од придружених референци више није доступна.", "single_missing": "Изгледа да придружена референца више није доступна." - }, - "password": { - "toggle_visible": "Сакриј лозинку", - "toggle_hidden": "Прикажи лозинку" } }, "message": { @@ -523,203 +357,161 @@ "loading": "Страница се учитава, сачекајте мало", "no": "Не", "not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.", - "yes": "Да", - "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?" + "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?", + "yes": "Да" }, "navigation": { - "no_results": "Није пронађен ниједан резултат", + "next": "Наредна", "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.", - "page_out_of_boundaries": "Број странице %{page} је ван опсега", - "page_out_from_end": "Не може да се иде након последње странице", + "no_results": "Није пронађен ниједан резултат", "page_out_from_begin": "Не може да се иде испред странице 1", + "page_out_from_end": "Не може да се иде након последње странице", + "page_out_of_boundaries": "Број странице %{page} је ван опсега", "page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}", "page_rows_per_page": "Ставки по страници:", - "next": "Наредна", - "prev": "Претх.", + "prev": "Претход", "skip_nav": "Прескочи на садржај" }, "notification": { - "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано", - "created": "Елемент је креиран", - "deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано", "bad_item": "Неисправни елемент", - "item_doesnt_exist": "Елемент не постоји", - "http_error": "Грешка у комуникацији са сервером", - "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", - "i18n_error": "Не могу да се учитају преводи за наведени језик", "canceled": "Акција је отказана", + "created": "Елемент је креиран", + "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", + "deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано", + "http_error": "Грешка у комуникацији са сервером", + "i18n_error": "Не могу да се учитају преводи за наведени језик", + "item_doesnt_exist": "Елемент не постоји", "logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.", - "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор." + "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.", + "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано" }, "toggleFieldsMenu": { "columnsToDisplay": "Колоне за приказ", - "layout": "Распоред", "grid": "Мрежа", + "layout": "Распоред", "table": "Табела" } }, "message": { - "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": "Да ли сте сигурни да желите да из базе података уклоните све фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", + "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": "Није доступна ниједна", + "note": "НАПОМЕНА", "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" }, - "lastfmLink": "Прочитај још...", - "shareOriginalFormat": "Подели у оригиналном формату", - "shareDialogTitle": "Подели %{resource} ’%{name}’", + "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", + "remove_missing_title": "Уклони фајлове који недостају", "shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}", "shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер", - "shareSuccess": "URL је копиран у клипборд: %{url}", + "shareDialogTitle": "Подели %{resource} ’%{name}’", "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд", - "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})", - "downloadOriginalFormat": "Преузми у оригиналном формату" + "shareOriginalFormat": "Подели у оригиналном формату", + "shareSuccess": "URL је копиран у клипборд: %{url}", + "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама", + "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.", + "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања." }, "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", - "replaygain": "ReplayGain режим", "preAmp": "ReplayGain претпојачање (dB)", - "gain": { - "none": "Искључено", - "album": "Користи Album појачање", - "track": "Користи Track појачање" - } + "replaygain": "ReplayGain режим", + "theme": "Тема" } }, - "albumList": "Албуми", "playlists": "Плејлисте", + "settings": "Подешавања", "sharedPlaylists": "Дељене плејлисте", - "about": "О" + "theme": "Тема", + "version": "Верзија" }, "player": { - "playListsText": "Ред за пуштање", - "openText": "Отвори", + "clickToDeleteText": "Кликните да обришете %{name}", + "clickToPauseText": "Кликни за паузирање", + "clickToPlayText": "Кликни за пуштање", "closeText": "Затвори", - "notContentText": "Нема музике", - "clickToPlayText": "Кликните за пуштање", - "clickToPauseText": "Кликните за паузирање", - "nextTrackText": "Наредна нумера", - "previousTrackText": "Претходна нумера", - "reloadText": "Поново учитај", - "volumeText": "Јачина", - "toggleLyricText": "Укљ./Искљ. стихове", - "toggleMiniModeText": "Умањи", "destroyText": "Уништи", "downloadText": "Преузми", - "removeAudioListsText": "Обриши аудио листе", - "clickToDeleteText": "Кликните да обришете %{name}", "emptyLyricText": "Нема стихова", + "nextTrackText": "Наредна нумера", + "notContentText": "Нема музике", + "openText": "Отвори", + "playListsText": "Ред за пуштање", "playModeText": { "order": "По редоследу", "orderLoop": "Понови", - "singleLoop": "Понови једну", - "shufflePlay": "Измешај" - } + "shufflePlay": "Измешај", + "singleLoop": "Понови једну" + }, + "previousTrackText": "Претходна нумера", + "reloadText": "Поново учитај", + "removeAudioListsText": "Обриши аудио листе", + "toggleLyricText": "Укљ./Искљ. стихове", + "toggleMiniModeText": "Умањи", + "volumeText": "Јачина" }, "about": { "links": { + "featureRequests": "Захтеви за функцијама", "homepage": "Почетна страница", - "source": "Изворни кôд", - "featureRequests": "Захтеви за функције", - "lastInsightsCollection": "Последња колекција увида", "insights": { "disabled": "Искључено", "waiting": "Чека се" - } - }, - "tabs": { - "about": "О програму", - "config": "Конфигурација" - }, - "config": { - "configName": "Назив конфигурације", - "environmentVariable": "Променљива окружења", - "currentValue": "Тренутна вредност", - "configurationFile": "Конфигурациони фајл", - "exportToml": "Извези конфигурацију (TOML)", - "downloadToml": "Преузми конфигурацију (TOML)", - "exportSuccess": "Конфигурација је извезена у клипборд у TOML формату", - "exportFailed": "Копирање конфигурације није успело", - "devFlagsHeader": "Развојне заставице (подложне промени или уклањању)", - "devFlagsComment": "Ово су експерименталне поставке и могу бити уклоњене у будућим верзијама" + }, + "lastInsightsCollection": "Последња колекција увида", + "source": "Изворни кôд" } }, "activity": { - "title": "Активност", - "totalScanned": "Укупан број скенираних фолдера", - "quickScan": "Брзо скенирање", "fullScan": "Комплетно скенирање", - "selectiveScan": "Селективно", - "serverUptime": "Сервер се извршава", + "quickScan": "Брзо скенирање", "serverDown": "ВАН МРЕЖЕ", - "scanType": "Последње скенирање", - "status": "Грешка скенирања", - "elapsedTime": "Протекло време" - }, - "nowPlaying": { - "title": "Сада се пушта", - "empty": "Ништа се не пушта", - "minutesAgo": "Пре %{smart_count} минут |||| Пре %{smart_count} минута" + "serverUptime": "Сервер се извршава", + "title": "Активност", + "totalScanned": "Укупан број скенираних фолдера" }, "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": "Утишај", - "toggle_love": "Додај ову нумеру у омиљене" + "vol_up": "Појачај" } } } diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index ff4f53a6b..228cc2cf3 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -11,22 +11,22 @@ "title": "Titel", "artist": "Artist", "album": "Album", - "path": "Filsökväg", + "path": "Sökväg", "genre": "Genre", - "compilation": "Samlingsalbum", + "compilation": "Samling", "year": "År", "size": "Filstorlek", "updatedAt": "Uppdaterad", - "bitRate": "Bithastighet", - "discSubtitle": "Skivans undertitel", + "bitRate": "Bitrate", + "discSubtitle": "Underrubrik", "starred": "Favorit", "comment": "Kommentar", "rating": "Betyg", "quality": "Kvalitet", "bpm": "BPM", "playDate": "Senast spelad", - "channels": "Kanaler", - "createdAt": "Tillagd", + "channels": "Channels", + "createdAt": "Skapad", "grouping": "Gruppering", "mood": "Stämning", "participants": "Ytterligare medverkande", @@ -35,21 +35,19 @@ "rawTags": "Omodifierade taggar", "bitDepth": "Bitdjup", "sampleRate": "Samplingsfrekvens", - "missing": "Saknas", + "missing": "Saknade", "libraryName": "Bibliotek", "composer": "Kompositör", - "disc": "Skiva %{discNumber}", - "albumGain": "Volymjustering (album)", - "trackGain": "Volymjustering (låt)" + "disc": "Disc %{discNumber}" }, "actions": { "addToQueue": "Lägg till i kön", "playNow": "Spela nu", "addToPlaylist": "Lägg till i spellista", - "shuffleAll": "Blanda alla", + "shuffleAll": "Shuffle", "download": "Ladda ner", - "playNext": "Spela härnäst", - "info": "Visa information", + "playNext": "Spela nästa", + "info": "Mer information", "showInPlaylist": "Visa i spellista", "instantMix": "Direktmix" } @@ -64,12 +62,12 @@ "playCount": "Spelningar", "name": "Namn", "genre": "Genre", - "compilation": "Samlingsalbum", + "compilation": "Samling", "year": "År", "updatedAt": "Uppdaterad", "comment": "Kommentar", "rating": "Betyg", - "createdAt": "Tillagt", + "createdAt": "Skapad", "size": "Storlek", "originalDate": "Originaldatum", "releaseDate": "Utgivningsdatum", @@ -82,28 +80,27 @@ "media": "Media", "mood": "Stämning", "date": "Inspelningsdatum", - "missing": "Saknas", + "missing": "Saknade", "libraryName": "Bibliotek" }, "actions": { "playAll": "Spela", "playNext": "Spela härnäst", "addToQueue": "Lägg till i kön", - "shuffle": "Blanda", + "shuffle": "Shuffle", "addToPlaylist": "Lägg till i spellista", "download": "Ladda ner", - "info": "Visa information", - "share": "Dela", - "refresh": "Uppdatera metadata" + "info": "Mer information", + "share": "Dela" }, "lists": { "all": "Alla", - "random": "Slumpmässiga", - "recentlyAdded": "Nyligen tillagda", - "recentlyPlayed": "Nyligen spelade", + "random": "Blanda", + "recentlyAdded": "Senast tillagda", + "recentlyPlayed": "Senast spelade", "mostPlayed": "Mest spelade", "starred": "Favoriter", - "topRated": "Högst betygsatta" + "topRated": "Bästa betyg" } }, "artist": { @@ -117,26 +114,26 @@ "genre": "Genre", "size": "Storlek", "role": "Roll", - "missing": "Saknas" + "missing": "Saknade" }, "roles": { "albumartist": "Albumartist |||| Albumartister", "artist": "Artist |||| Artister", - "composer": "Kompositör |||| Kompositörer", + "composer": "Kompositör |||| Kompositörer", "conductor": "Dirigent |||| Dirigenter", "lyricist": "Textförfattare |||| Textförfattare", "arranger": "Arrangör |||| Arrangörer", "producer": "Producent |||| Producenter", "director": "Inspelningsledare |||| Inspelningsledare", "engineer": "Ljudtekniker |||| Ljudtekniker", - "mixer": "Mixare |||| Mixare", - "remixer": "Remixare |||| Remixare", - "djmixer": "DJ-mixare |||| DJ-mixare", - "performer": "Utövande artist |||| Utövande artister", - "maincredit": "Albumartist eller artist |||| Albumartister eller artister" + "mixer": "Mixare |||| Mixare", + "remixer": "Remixare |||| Remixare", + "djmixer": "DJ-mixare |||| DJ-mixare", + "performer": "Utövande artist |||| Utövande artister", + "maincredit": "Albumartister eller Artist |||| Albumartister eller Artister" }, "actions": { - "shuffle": "Blanda", + "shuffle": "Shuffle", "radio": "Radio", "topSongs": "Topplåtar" } @@ -145,7 +142,7 @@ "name": "Användare |||| Användare", "fields": { "userName": "Användarnamn", - "isAdmin": "Administratör", + "isAdmin": "Är admin", "lastLoginAt": "Senaste inloggning", "updatedAt": "Uppdaterad", "name": "Namn", @@ -154,15 +151,13 @@ "changePassword": "Byt lösenord?", "currentPassword": "Nuvarande lösenord", "newPassword": "Nytt lösenord", - "token": "Åtkomsttoken", + "token": "Token", "lastAccessAt": "Senaste åtkomst", - "libraries": "Bibliotek", - "scrobbleFilter": "Scrobblingsfilter" + "libraries": "Bibliotek" }, "helperTexts": { "name": "Ändringar av ditt namn syns först vid nästa inloggning", - "libraries": "Välj vilka bibliotek användaren ska ha tillgång till, eller lämna tomt för att använda standardbiblioteken", - "scrobbleFilter": "Låtar som matchar dessa regler för smarta spellistor skickas inte till Last.fm, ListenBrainz eller scrobblingtillägg. Samma JSON-syntax och beteende som för smarta spellistor används. Exempel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Lämna tomt för att scrobbla allt. Antalet spelningar som lagras lokalt påverkas inte." + "libraries": "Välj ett bibliotek för denna användare eller lämna blankt för standardbibliotek" }, "notifications": { "created": "Användare skapad", @@ -170,14 +165,13 @@ "deleted": "Användare borttagen" }, "message": { - "listenBrainzToken": "Ange din användartoken för ListenBrainz.", + "listenBrainzToken": "Ange din ListenBrainz användar-token.", "clickHereForToken": "Klicka här för att hämta din token", "selectAllLibraries": "Välj alla bibliotek", - "adminAutoLibraries": "Administratörer har automatiskt åtkomst till alla bibliotek" + "adminAutoLibraries": "Administratörer har automatiskt tillgång till alla bibliotek" }, "validation": { - "librariesRequired": "Minst ett bibliotek måste väljas för användare som inte är administratörer", - "invalidScrobbleFilter": "Måste innehålla giltiga regler för smarta spellistor. Begränsning av antalet låtar, förskjutning och uppdateringsfördröjning stöds inte." + "librariesRequired": "Minst ett bibliotek måste väljas för icke-administratörer" } }, "player": { @@ -185,12 +179,12 @@ "fields": { "name": "Namn", "transcodingId": "Omkodning", - "maxBitRate": "Högsta bithastighet", + "maxBitRate": "Max. bitrate", "client": "Klient", "userName": "Användarnamn", "lastSeen": "Senast sedd", - "reportRealPath": "Rapportera den faktiska sökvägen", - "scrobbleEnabled": "Scrobbla till externa tjänster" + "reportRealPath": "Visa hela sökvägen", + "scrobbleEnabled": "Scrobbla till extern tjänst" } }, "transcoding": { @@ -198,11 +192,8 @@ "fields": { "name": "Namn", "targetFormat": "Målformat", - "defaultBitRate": "Standardbithastighet", + "defaultBitRate": "Standardbitrate", "command": "Kommando" - }, - "choices": { - "noDefaultBitRate": "Ingen" } }, "playlist": { @@ -216,9 +207,8 @@ "createdAt": "Skapad", "songCount": "Låtar", "comment": "Kommentar", - "sync": "Automatisk import", - "path": "Importera från", - "starred": "Favorit" + "sync": "Auto-import", + "path": "Importera från" }, "actions": { "selectPlaylist": "Välj en spellista:", @@ -226,24 +216,24 @@ "export": "Exportera", "makePublic": "Gör offentlig", "makePrivate": "Gör privat", - "saveQueue": "Spara kön i en spellista", - "searchOrCreate": "Sök spellistor eller skriv för att skapa en ny...", - "pressEnterToCreate": "Tryck på Enter för att skapa en ny spellista", - "removeFromSelection": "Ta bort från urvalet" + "saveQueue": "Spara kö till spellista", + "searchOrCreate": "Sök spellista eller skapa ny...", + "pressEnterToCreate": "Tryck Enter för att skapa ny spellista", + "removeFromSelection": "Ta bort från urval" }, "message": { - "duplicate_song": "Lägg till dubbletter", - "song_exist": "Du håller på att lägga till dubbletter i spellistan. Vill du lägga till dem eller hoppa över dem?", - "noPlaylistsFound": "Inga spellistor hittades", + "duplicate_song": "Lägg till dubletter", + "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?", + "noPlaylistsFound": "Hittade inga spellistor", "noPlaylists": "Inga spellistor tillgängliga" } }, "radio": { - "name": "Radiostation |||| Radiostationer", + "name": "Radio |||| Radior", "fields": { "name": "Namn", - "streamUrl": "Strömmens URL", - "homePageUrl": "Webbplatsens URL", + "streamUrl": "Stream-URL", + "homePageUrl": "Hemside-URL", "updatedAt": "Uppdaterad", "createdAt": "Skapad" }, @@ -252,7 +242,7 @@ } }, "share": { - "name": "Delning |||| Delningar", + "name": "Dela |||| Delningar", "fields": { "username": "Delad av", "url": "URL", @@ -262,13 +252,11 @@ "lastVisitedAt": "Senast besökt", "visitCount": "Besök", "format": "Format", - "maxBitRate": "Högsta bithastighet", + "maxBitRate": "Max. bitrate", "updatedAt": "Uppdaterad", "createdAt": "Skapad", - "downloadable": "Tillåt nedladdningar?" - }, - "notifications": {}, - "actions": {} + "downloadable": "Tillåt nedladdning?" + } }, "missing": { "name": "Saknad fil |||| Saknade filer", @@ -279,11 +267,11 @@ "libraryName": "Bibliotek" }, "actions": { - "remove": "Ta bort", - "remove_all": "Ta bort alla" + "remove": "Radera", + "remove_all": "Radera alla" }, "notifications": { - "removed": "Saknade filer har tagits bort" + "removed": "Saknade fil(er) borttagna" }, "empty": "Inga saknade filer" }, @@ -292,8 +280,8 @@ "fields": { "name": "Namn", "path": "Sökväg", - "remotePath": "Fjärrsökväg", - "lastScanAt": "Senaste skanning", + "remotePath": "Ta bort sökväg", + "lastScanAt": "Senaste scan", "songCount": "Låtar", "albumCount": "Album", "artistCount": "Artister", @@ -314,33 +302,33 @@ "statistics": "Statistik" }, "actions": { - "scan": "Skanna biblioteket", + "scan": "Scanna bibliotek", "manageUsers": "Hantera användaråtkomst", - "viewDetails": "Visa detaljer", - "quickScan": "Snabbskanning", - "fullScan": "Fullständig skanning" + "viewDetails": "Se detaljer", + "quickScan": "Snabbscan", + "fullScan": "Komplett scan" }, "notifications": { "created": "Biblioteket har skapats", "updated": "Biblioteket har uppdaterats", - "deleted": "Biblioteket har tagits bort", - "scanStarted": "Skanning av biblioteket har startat", - "scanCompleted": "Skanning av biblioteket är klar", - "quickScanStarted": "Snabbskanning har startat", - "fullScanStarted": "Fullständig skanning har startat", - "scanError": "Kunde inte starta skanningen. Kontrollera loggarna" + "deleted": "Biblioteket har raderats", + "scanStarted": "Biblioteksscan startad", + "scanCompleted": "Biblioteksscan avslutad", + "quickScanStarted": "Snabbscan startad", + "fullScanStarted": "Komplett scan startad", + "scanError": "Fel vid start av scan. Se loggarna" }, "validation": { - "nameRequired": "Ange ett biblioteksnamn", - "pathRequired": "Ange en sökväg till biblioteket", - "pathNotDirectory": "Bibliotekets sökväg måste peka på en katalog", - "pathNotFound": "Bibliotekets sökväg hittades inte", - "pathNotAccessible": "Bibliotekets sökväg är inte åtkomlig", - "pathInvalid": "Ogiltig sökväg till biblioteket" + "nameRequired": "Biblioteksnamn krävs", + "pathRequired": "Bibliotekssökväg krävs", + "pathNotDirectory": "Bibliotekssökvägen måste vara en katalog", + "pathNotFound": "Bibliotekssökväg hittades inte", + "pathNotAccessible": "Bibliotekssökväg inte tillgänglig", + "pathInvalid": "Ogiltig bibliotekssökväg" }, "messages": { - "deleteConfirm": "Är du säker på att du vill ta bort det här biblioteket? Alla tillhörande data och användarnas åtkomst till biblioteket tas bort.", - "scanInProgress": "Skanning pågår...", + "deleteConfirm": "Är du säker på att du vill ta bort detta bibliotek? Detta raderar all förbunden data och användartillgång.", + "scanInProgress": "Scanning pågår...", "noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren" } }, @@ -351,44 +339,44 @@ "name": "Namn", "description": "Beskrivning", "version": "Version", - "author": "Upphovsperson", - "website": "Webbplats", + "author": "Författare", + "website": "Website", "permissions": "Behörigheter", - "enabled": "Aktiverat", + "enabled": "Aktiverad", "status": "Status", "path": "Sökväg", "lastError": "Fel", "hasError": "Fel", - "updatedAt": "Uppdaterat", - "createdAt": "Installerat", + "updatedAt": "Uppdaterad", + "createdAt": "Installerad", "configKey": "Nyckel", "configValue": "Värde", "allUsers": "Tillåt alla användare", "selectedUsers": "Valda användare", "allLibraries": "Tillåt alla bibliotek", "selectedLibraries": "Valda bibliotek", - "allowWriteAccess": "Tillåt skrivåtkomst" + "allowWriteAccess": "Tillåt skrivrättigheter" }, "sections": { "status": "Status", - "info": "Information om tillägget", + "info": "Tilläggsinformation", "configuration": "Konfiguration", "manifest": "Manifest", - "usersPermission": "Åtkomst till användare", - "libraryPermission": "Åtkomst till bibliotek" + "usersPermission": "Användarbehörigheter", + "libraryPermission": "Biblioteksbehörigheter" }, "status": { - "enabled": "Aktiverat", - "disabled": "Inaktiverat" + "enabled": "Aktiverad", + "disabled": "Inaktiverad" }, "actions": { "enable": "Aktivera", "disable": "Inaktivera", - "disabledDueToError": "Åtgärda felet före aktivering", + "disabledDueToError": "Åtgärda felet innan aktivering", "disabledUsersRequired": "Välj användare före aktivering", "disabledLibrariesRequired": "Välj bibliotek före aktivering", "addConfig": "Lägg till konfiguration", - "rescan": "Skanna om" + "rescan": "Scanna om" }, "notifications": { "enabled": "Tillägg aktiverat", @@ -403,18 +391,17 @@ "configHelp": "Konfigurera tillägget med nyckel–värde-par. Lämna tomt om tillägget inte kräver någon konfiguration.", "clickPermissions": "Klicka på en behörighet för mer information", "noConfig": "Ingen konfiguration angiven", - "allUsersHelp": "När alternativet är aktiverat får tillägget åtkomst till alla användare, även de som skapas i framtiden.", + "allUsersHelp": "När den är aktiverad får tillägget tillgång till alla användare, inklusive de som skapas i framtiden.", "noUsers": "Inga användare valda", "permissionReason": "Orsak", - "usersRequired": "Det här tillägget behöver åtkomst till användarinformation. Välj vilka användare tillägget får åtkomst till eller aktivera 'Tillåt alla användare'.", - "allLibrariesHelp": "När alternativet är aktiverat får tillägget åtkomst till alla bibliotek, även de som skapas i framtiden.", + "usersRequired": "Detta tillägg kräver åtkomst till användarinformation. Välj vilka användare insticksprogrammet ska ha åtkomst till, eller aktivera 'Tillåt alla användare'.", + "allLibrariesHelp": "När den är aktiverad får tillägget tillgång till alla bibliotek, inklusive de som skapas i framtiden.", "noLibraries": "Inga bibliotek valda", - "librariesRequired": "Det här tillägget behöver åtkomst till biblioteksinformation. Välj vilka bibliotek tillägget får åtkomst till eller aktivera 'Tillåt alla bibliotek'.", - "requiredHosts": "Värdar som krävs", + "librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.", + "requiredHosts": "Krävda värdar", "configValidationError": "Validering av konfigurationen misslyckades:", - "schemaRenderError": "Kunde inte visa konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", - "allowWriteAccessHelp": "När alternativet är aktiverat kan tillägget ändra filer i bibliotekens kataloger. Som standard har tillägg endast läsåtkomst.", - "idHelp": "Tilläggets ID hämtas från filnamnet. Använd det när du hänvisar till tillägget i konfigurationsalternativ, till exempel Agents." + "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", + "allowWriteAccessHelp": "När detta är aktiverat kan tillägget ändra filer i bibliotekets kataloger. Som standard har tillägget endast läsrättigheter." }, "placeholders": { "configKey": "nyckel", @@ -425,40 +412,40 @@ "ra": { "auth": { "welcome1": "Tack för att du installerade Navidrome!", - "welcome2": "Börja med att skapa ett administratörskonto", + "welcome2": "Skapa först ett admin-konto", "confirmPassword": "Bekräfta lösenord", - "buttonCreateAdmin": "Skapa administratörskonto", + "buttonCreateAdmin": "Skapa admin-konto", "auth_check_error": "Logga in för att fortsätta", "user_menu": "Profil", "username": "Användarnamn", "password": "Lösenord", "sign_in": "Logga in", - "sign_in_error": "Inloggningen misslyckades. Försök igen", + "sign_in_error": "Felaktig inloggning, försök igen", "logout": "Logga ut", - "insightsCollectionNote": "Navidrome samlar in anonyma användningsdata för\natt förbättra projektet. Klicka [här] för att läsa mer\noch välja bort insamlingen" + "insightsCollectionNote": "Navidrome samlar anonym användardata för att\nhjälpa projektet att bli bättre. Klicka [här]\nför att läsa mer och avaktivera om du vill" }, "validation": { "invalidChars": "Använd enbart bokstäver och siffror", - "passwordDoesNotMatch": "Lösenorden stämmer inte överens", - "required": "Obligatoriskt", - "minLength": "Måste innehålla minst %{min} tecken", - "maxLength": "Får innehålla högst %{max} tecken", + "passwordDoesNotMatch": "Lösenordet matchar inte", + "required": "Krävs", + "minLength": "Måste ha minst %{min} tecken", + "maxLength": "Får maximalt ha %{max} tecken", "minValue": "Måste vara minst %{min}", - "maxValue": "Får vara högst %{max}", - "number": "Måste vara ett tal", + "maxValue": "Får maximalt vara %{max}", + "number": "Måste vara ett nummer", "email": "Måste vara en giltig e-postadress", - "oneOf": "Måste vara något av: %{options}", - "regex": "Måste matcha det reguljära uttrycket: %{pattern}", - "unique": "Måste vara unikt", + "oneOf": "Måste vara en av: %{options}", + "regex": "Måste matcha ett specifikt format (regexp): %{pattern}", + "unique": "Måste vara unik", "url": "Måste vara en giltig URL" }, "action": { "add_filter": "Lägg till filter", "add": "Lägg till", "back": "Tillbaka", - "bulk_actions": "1 objekt valt |||| %{smart_count} objekt valda", + "bulk_actions": "1 objekt vald |||| %{smart_count} objekt valda", "cancel": "Avbryt", - "clear_input_value": "Rensa värdet", + "clear_input_value": "Rensa", "clone": "Klona", "confirm": "Bekräfta", "create": "Skapa", @@ -467,8 +454,8 @@ "export": "Exportera", "list": "Lista", "refresh": "Uppdatera", - "remove_filter": "Ta bort det här filtret", - "remove": "Ta bort", + "remove_filter": "Ta bort filter", + "remove": "Radera", "save": "Spara", "search": "Sök", "show": "Visa", @@ -490,15 +477,15 @@ }, "page": { "create": "Skapa %{name}", - "dashboard": "Översikt", + "dashboard": "Dashboard", "edit": "%{name} #%{id}", "error": "Ett fel uppstod", "list": "%{name}", - "loading": "Läser in", - "not_found": "Hittades inte", + "loading": "Laddar", + "not_found": "Hittade inget", "show": "%{name} #%{id}", - "empty": "%{name}: inga poster ännu.", - "invite": "Vill du lägga till något?" + "empty": "Ingen %{name} ännu.", + "invite": "Vill du lägga till en?" }, "input": { "file": { @@ -510,13 +497,13 @@ "upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att välja en bild." }, "references": { - "all_missing": "Kunde inte hitta referensdata.", - "many_missing": "Minst en av de kopplade referenserna verkar inte längre vara tillgänglig.", - "single_missing": "Den kopplade referensen verkar inte längre vara tillgänglig." + "all_missing": "Hittade ingen referensdata.", + "many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.", + "single_missing": "Associerade referenser verkar inte längre vara tillgängliga." }, "password": { - "toggle_visible": "Dölj lösenord", - "toggle_hidden": "Visa lösenord" + "toggle_visible": "Dölj password", + "toggle_hidden": "Visa password" } }, "message": { @@ -524,16 +511,16 @@ "are_you_sure": "Är du säker?", "bulk_delete_content": "Vill du verkligen ta bort %{name}? |||| Vill du verkligen ta bort dessa %{smart_count} objekt?", "bulk_delete_title": "Ta bort %{name} |||| Ta bort %{smart_count} %{name}", - "delete_content": "Vill du verkligen ta bort det här objektet?", + "delete_content": "Vill du verkligen ta bort detta innehåll?", "delete_title": "Ta bort %{name} #%{id}", "details": "Detaljer", "error": "Ett klientfel uppstod och begäran kunde inte slutföras.", - "invalid_form": "Formuläret innehåller fel. Kontrollera uppgifterna", - "loading": "Sidan läses in. Vänta ett ögonblick", + "invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel", + "loading": "Sidan läses in, var god vänta", "no": "Nej", "not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.", "yes": "Ja", - "unsaved_changes": "Vissa ändringar har inte sparats. Vill du verkligen ignorera dem?" + "unsaved_changes": "Du har osparade ändringar. Ignorera dem?" }, "navigation": { "no_results": "Inga resultat hittades", @@ -542,23 +529,23 @@ "page_out_from_end": "Det finns inga fler sidor", "page_out_from_begin": "Det finns ingen sida före sida 1", "page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}", - "page_rows_per_page": "Objekt per sida:", + "page_rows_per_page": "Antal per sida:", "next": "Nästa", "prev": "Föregående", "skip_nav": "Hoppa till innehåll" }, "notification": { - "updated": "Objekt uppdaterat |||| %{smart_count} objekt uppdaterade", - "created": "Objekt skapat", - "deleted": "Objekt borttaget |||| %{smart_count} objekt borttagna", - "bad_item": "Felaktigt objekt", - "item_doesnt_exist": "Objektet finns inte", + "updated": "Element uppdaterat |||| %{smart_count} element uppdaterade", + "created": "Element skapat", + "deleted": "Element borttaget |||| %{smart_count} element borttagna", + "bad_item": "Felaktigt element", + "item_doesnt_exist": "Element finns inte", "http_error": "Kommunikationsfel med servern", - "data_provider_error": "Fel i dataProvider. Kontrollera webbläsarens konsol för mer information.", - "i18n_error": "Kunde inte läsa in översättningarna för det valda språket", + "data_provider_error": "Fel i dataProvider. Kontrollera din konsol för mer information.", + "i18n_error": "Kunde inte läsa in översättningen av det valda språket", "canceled": "Åtgärden avbröts", - "logged_out": "Sessionen har avslutats. Logga in igen.", - "new_version": "En ny version finns tillgänglig! Ladda om det här fönstret." + "logged_out": "Sessionen har avslutats, anslut på nytt.", + "new_version": "Det finns en ny version! Uppdatera detta fönster." }, "toggleFieldsMenu": { "columnsToDisplay": "Kolumner att visa", @@ -569,39 +556,39 @@ }, "message": { "note": "OBSERVERA", - "transcodingDisabled": "Av säkerhetsskäl går det inte att ändra omkodningsinställningarna i webbgränssnittet. Starta om servern med konfigurationsalternativet %{config} för att redigera eller lägga till omkodningsinställningar.", - "transcodingEnabled": "Navidrome körs med %{config}. Det gör det möjligt att köra systemkommandon via omkodningsinställningarna i webbgränssnittet. Av säkerhetsskäl rekommenderar vi att alternativet är inaktiverat och bara aktiveras när omkodningen ska konfigureras.", - "songsAddedToPlaylist": "1 låt har lagts till i spellistan |||| %{smart_count} låtar har lagts till i spellistan", - "noPlaylistsAvailable": "Inga tillgängliga", - "delete_user_title": "Ta bort användaren '%{name}'", - "delete_user_content": "Är du säker på att du vill ta bort den här användaren och alla användarens data, inklusive spellistor och inställningar?", - "notifications_blocked": "Du har blockerat aviseringar från den här webbplatsen i webbläsarens inställningar", - "notifications_not_available": "Webbläsaren stöder inte skrivbordsaviseringar, eller så ansluter du inte till Navidrome via HTTPS", - "lastfmLinkSuccess": "Last.fm har kopplats och scrobbling har aktiverats", - "lastfmLinkFailure": "Det gick inte att koppla Last.fm", - "lastfmUnlinkSuccess": "Kopplingen till Last.fm har tagits bort och scrobbling har inaktiverats", - "lastfmUnlinkFailure": "Det gick inte att ta bort kopplingen till Last.fm", + "transcodingDisabled": "Inställning för kodning via webbgränssnittet är av säkerhetsskäl ej aktiverat. Starta om servern med alternativet %{config} markerat om du vill göra ändringar (redigera eller lägga till).", + "transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.", + "songsAddedToPlaylist": "La till en låt i spellistan |||| La till %{smart_count} låtar i spellistan", + "noPlaylistsAvailable": "Ingen tillgänglig", + "delete_user_title": "Ta bort användare '%{name}'", + "delete_user_content": "Är du säker på att du vill ta bort denna användare (inklusive spellistor och inställningar)?", + "notifications_blocked": "Du har blockerat meddelanden från denna sajt in din webbläsares inställningar", + "notifications_not_available": "Denna webbläsare stödjer inte skrivbordsmeddelanden eller du använder inte Navidrome via https", + "lastfmLinkSuccess": "Last.fm är länkat och scrobbling är aktivt", + "lastfmLinkFailure": "Last.fm kunde inte länkas", + "lastfmUnlinkSuccess": "Last.fm är inte längre länkat och scrobbling är deaktiverat", + "lastfmUnlinkFailure": "Last.fm kunde inte avlänkas", "openIn": { "lastfm": "Öppna i Last.fm", "musicbrainz": "Öppna i MusicBrainz" }, "lastfmLink": "Läs mer...", - "listenBrainzLinkSuccess": "ListenBrainz har kopplats och scrobbling har aktiverats för användaren: %{user}", - "listenBrainzLinkFailure": "Det gick inte att koppla ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "Kopplingen till ListenBrainz har tagits bort och scrobbling har inaktiverats", - "listenBrainzUnlinkFailure": "Det gick inte att ta bort kopplingen till ListenBrainz", + "listenBrainzLinkSuccess": "ListenBrainz är länkat och scrobbling är aktivt som användare: %{user}", + "listenBrainzLinkFailure": "ListenBrainz kunde inte länkas: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz är inte längre länkat och scrobbling är deaktiverat", + "listenBrainzUnlinkFailure": "ListenBrainz kunde inte avlänkas", "downloadOriginalFormat": "Ladda ner i originalformat", "shareOriginalFormat": "Dela i originalformat", "shareDialogTitle": "Dela %{resource} '%{name}'", - "shareBatchDialogTitle": "Dela 1 %{resource} |||| Dela %{smart_count} %{resource}", - "shareSuccess": "URL:en har kopierats till urklipp: %{url}", - "shareFailure": "Kunde inte kopiera URL:en %{url} till urklipp", + "shareBatchDialogTitle": "Dela en %{resource} |||| Dela %{smart_count} %{resource}", + "shareSuccess": "URL kopierades till urklipp: %{url}", + "shareFailure": "Fel vid kopiering av URL %{url} till urklipp", "downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter", "remove_missing_title": "Ta bort saknade filer", - "remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.", + "remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", "remove_all_missing_title": "Ta bort alla saknade filer", - "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.", + "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", "noSimilarSongsFound": "Hittade inga liknande låtar", "noTopSongsFound": "Hittade inga topplåtar", "startingInstantMix": "Laddar direktmix...", @@ -610,11 +597,7 @@ "coverUploaded": "Omslagsbild uppdaterad", "coverRemoved": "Omslagsbild borttagen", "coverUploadError": "Fel vid uppladdning av omslagsbild", - "coverRemoveError": "Fel vid borttagning av omslagsbild", - "metadataRefreshStarted": "Metadata uppdateras i bakgrunden", - "quickConnectApproved": "%{app} på %{device} är nu inloggad", - "quickConnectInvalidCode": "Koden är ogiltig eller har gått ut", - "quickConnectError": "Kunde inte godkänna koden" + "coverRemoveError": "Fel vid borttagning av omslagsbild" }, "menu": { "library": "Bibliotek", @@ -627,17 +610,17 @@ "theme": "Tema", "language": "Språk", "defaultView": "Standardvy", - "desktop_notifications": "Skrivbordsaviseringar", + "desktop_notifications": "Skrivbordsmeddelanden", "lastfmScrobbling": "Scrobbla till Last.fm", "listenBrainzScrobbling": "Scrobbla till ListenBrainz", "replaygain": "ReplayGain-läge", - "preAmp": "Förförstärkning för ReplayGain (dB)", + "preAmp": "ReplayGain PreAmp (dB)", "gain": { "none": "Inaktiverad", - "album": "Använd albumets volymjustering", - "track": "Använd låtens volymjustering" + "album": "Använd gain för album", + "track": "Använd gain für låtar" }, - "lastfmNotConfigured": "API-nyckeln för Last.fm är inte konfigurerad" + "lastfmNotConfigured": "Last.fm API-nyckel är inte konfigurerad" } }, "albumList": "Album", @@ -649,19 +632,10 @@ "multipleLibraries": "%{selected} av %{total} bibliotek", "selectLibraries": "Välj bibliotek", "none": "Inga" - }, - "quickConnect": { - "name": "Snabbanslutning", - "code": "Kod", - "help": "Ange koden som visas i en Jellyfin-app för att logga in på ditt konto i appen", - "confirm": "Vill du logga in med ditt konto i %{app} %{version} på %{device}?", - "continue": "Fortsätt", - "approve": "Godkänn" - }, - "onlyFavourites": "Visa endast favoriter" + } }, "player": { - "playListsText": "Uppspelningskö", + "playListsText": "Spela kön", "openText": "Öppna", "closeText": "Stäng", "notContentText": "Ingen musik", @@ -671,26 +645,26 @@ "previousTrackText": "Föregående låt", "reloadText": "Ladda om", "volumeText": "Volym", - "toggleLyricText": "Visa eller dölj låttexten", + "toggleLyricText": "Låttext av/på", "toggleMiniModeText": "Minimera", - "destroyText": "Stäng spelaren och rensa kön", + "destroyText": "Radera", "downloadText": "Ladda ner", - "removeAudioListsText": "Rensa kön", + "removeAudioListsText": "Ta bort audiolistor", "clickToDeleteText": "Klicka för att ta bort %{name}", "emptyLyricText": "Ingen låttext", "playModeText": { "order": "I ordningsföljd", - "orderLoop": "Upprepa alla", - "singleLoop": "Upprepa en låt", - "shufflePlay": "Blanda" + "orderLoop": "Upprepa", + "singleLoop": "Upprepa en", + "shufflePlay": "Shuffle" } }, "about": { "links": { - "homepage": "Webbplats", + "homepage": "Hemsida", "source": "Källkod", - "featureRequests": "Förslag på nya funktioner", - "lastInsightsCollection": "Senaste insamling av användningsdata", + "featureRequests": "Funktionalitetförfrågan", + "lastInsightsCollection": "Senaste Insights-kollektion", "insights": { "disabled": "Inaktiverad", "waiting": "Väntar" @@ -698,16 +672,16 @@ }, "tabs": { "about": "Om", - "config": "Konfiguration" + "config": "Inställningar" }, "config": { - "configName": "Inställning", + "configName": "Inställningsnamn", "environmentVariable": "Miljövariabel", - "currentValue": "Aktuellt värde", - "configurationFile": "Konfigurationsfil", - "exportToml": "Exportera konfiguration (TOML)", - "exportSuccess": "Konfigurationen har kopierats till urklipp i TOML-format", - "exportFailed": "Kunde inte kopiera konfigurationen", + "currentValue": "Nuvarande värde", + "configurationFile": "Inställningsfil", + "exportToml": "Exportera inställningar (TOML)", + "exportSuccess": "Inställningarna kopierade till urklippet i TOML-format", + "exportFailed": "Kopiering av inställningarna misslyckades", "devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)", "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner", "downloadToml": "Ladda ner konfiguration (TOML)" @@ -715,28 +689,28 @@ }, "activity": { "title": "Aktivitet", - "totalScanned": "Skannade mappar totalt", - "quickScan": "Snabb", - "fullScan": "Fullständig", - "serverUptime": "Serverns drifttid", + "totalScanned": "Genomsökta mappar", + "quickScan": "Snabbscan", + "fullScan": "Komplett scan", + "serverUptime": "Serverdrifttid", "serverDown": "OFFLINE", - "scanType": "Senaste skanning", - "status": "Skanningsfel", - "elapsedTime": "Förfluten tid", - "selectiveScan": "Selektiv" + "scanType": "Typ", + "status": "Fel vid scanning", + "elapsedTime": "Spelad tid", + "selectiveScan": "Urval" }, "help": { - "title": "Kortkommandon i Navidrome", + "title": "Navidrome kortkommandon", "hotkeys": { "show_help": "Visa denna hjälp", - "toggle_menu": "Visa eller dölj sidomenyn", + "toggle_menu": "Växla sidomeny", "toggle_play": "Spela / pausa", "prev_song": "Föregående låt", "next_song": "Nästa låt", - "vol_up": "Höj volymen", - "vol_down": "Sänk volymen", - "toggle_love": "Lägg till låten i favoriter", - "current_song": "Gå till aktuell låt" + "vol_up": "Volym upp", + "vol_down": "Volym ner", + "toggle_love": "Lägg till låt i favoriter", + "current_song": "Hoppa till nuvarande låt" } }, "nowPlaying": { @@ -744,4 +718,4 @@ "empty": "Inget spelas", "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" } -} +} \ No newline at end of file diff --git a/resources/i18n/th.json b/resources/i18n/th.json index 3c5c9e07b..b445d7464 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -38,9 +38,7 @@ "missing": "หายไป", "libraryName": "ห้องสมุด", "composer": "ผู้แต่ง", - "disc": "พื้นที่ %{discNumber}", - "albumGain": "เนื้อหาในอัลบั้ม", - "trackGain": "เนื้อหาในเพลง" + "disc": "" }, "actions": { "addToQueue": "เพิ่มในคิว", @@ -93,8 +91,7 @@ "addToPlaylist": "เพิ่มลงในเพลย์ลิสต์", "download": "ดาวน์โหลด", "info": "ดูรายละเอียด", - "share": "แบ่งปัน", - "refresh": "" + "share": "แบ่งปัน" }, "lists": { "all": "ทั้งหมด", @@ -156,13 +153,11 @@ "newPassword": "รหัสผ่านใหม่", "token": "โทเคน", "lastAccessAt": "เข้าใช้ล่าสุด", - "libraries": "ห้องสมุด", - "scrobbleFilter": "" + "libraries": "ห้องสมุด" }, "helperTexts": { "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป", - "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น", - "scrobbleFilter": "" + "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น" }, "notifications": { "created": "สร้างชื่อผู้ใช้", @@ -176,8 +171,7 @@ "adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ" }, "validation": { - "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล", - "invalidScrobbleFilter": "" + "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล" } }, "player": { @@ -200,9 +194,6 @@ "targetFormat": "ชนิดไฟล์เสียง", "defaultBitRate": "บิตเรท", "command": "คำสั่ง" - }, - "choices": { - "noDefaultBitRate": "" } }, "playlist": { @@ -217,8 +208,7 @@ "songCount": "เพลง", "comment": "ความคิดเห็น", "sync": "นำเข้าอัตโนมัติ", - "path": "นำเข้าจาก", - "starred": "ชื่นชอบ" + "path": "นำเข้าจาก" }, "actions": { "selectPlaylist": "เลือกเพลย์ลิสต์", @@ -365,7 +355,7 @@ "selectedUsers": "ผู้ใช้ถูกเลือก", "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด", "selectedLibraries": "ห้องสมุดเพลงถูกเลือก", - "allowWriteAccess": "อนุญาตให้เขียน" + "allowWriteAccess": "" }, "sections": { "status": "สถานะ", @@ -411,8 +401,7 @@ "requiredHosts": "ต้องการ Host", "configValidationError": "การตั้งค่าเกิดความผิดพลาด", "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", - "allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น", - "idHelp": "" + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "คีย์", @@ -602,14 +591,7 @@ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", "noTopSongsFound": "ไม่พบเพลงยอดนิยม", - "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก...", - "uploadCover": "อัพโหลดภาพหน้าปก", - "removeCover": "ลบถาพหน้าปก", - "coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว", - "coverRemoved": "ภาพหน้าปกถูกลบแล้ว", - "coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด", - "coverRemoveError": "ลบภาพหน้าปกผิดพลาด", - "metadataRefreshStarted": "" + "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..." }, "menu": { "library": "ห้องสมุดเพลง", @@ -644,8 +626,7 @@ "multipleLibraries": "%{selected} ของ %{total} ห้องสมุด", "selectLibraries": "เลือกห้องสมุด", "none": "ไม่มี" - }, - "onlyFavourites": "แสดงเฉพาะที่ชื่นชอบ" + } }, "player": { "playListsText": "คิวเล่น", @@ -731,4 +712,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 ff387aff8..d1fdb2ed4 100644 --- a/resources/i18n/tr.json +++ b/resources/i18n/tr.json @@ -36,11 +36,7 @@ "bitDepth": "Bit derinliği", "sampleRate": "Örnekleme Oranı", "missing": "Eksik", - "libraryName": "Kütüphane", - "composer": "Besteci", - "disc": "Disk %{discNumber}", - "albumGain": "Albüm Kazancı", - "trackGain": "Parça Kazancı" + "libraryName": "Kütüphane" }, "actions": { "addToQueue": "Oynatma Sırasına Ekle", @@ -50,8 +46,7 @@ "download": "İndir", "playNext": "Dinlenenden Sonra Oynat", "info": "Bilgiler", - "showInPlaylist": "Çalma Listesinde Göster", - "instantMix": "Anında Karışım" + "showInPlaylist": "Çalma Listesinde Göster" } }, "album": { @@ -333,82 +328,6 @@ "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": { @@ -592,14 +511,7 @@ "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ş", - "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" + "noTopSongsFound": "En iyi şarkı listesi boş" }, "menu": { "library": "Kütüphane", @@ -685,8 +597,7 @@ "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", - "downloadToml": "Yapılandırmayı İndir (TOML)" + "devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir" } }, "activity": { diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json index 3049f9840..c5644fde7 100644 --- a/resources/i18n/uk.json +++ b/resources/i18n/uk.json @@ -38,9 +38,7 @@ "missing": "Поле відсутнє", "libraryName": "Бібліотека", "composer": "Композитор", - "disc": "Диск %{discNumber}", - "albumGain": "", - "trackGain": "" + "disc": "Диск %{discNumber}" }, "actions": { "addToQueue": "Прослухати пізніше", @@ -93,8 +91,7 @@ "addToPlaylist": "Додати у список відтворення", "download": "Завантажити", "info": "Отримати інформацію", - "share": "Поширити", - "refresh": "" + "share": "Поширити" }, "lists": { "all": "Усі", @@ -156,13 +153,11 @@ "newPassword": "Новий пароль", "token": "Токен", "lastAccessAt": "Останній доступ", - "libraries": "Бібліотеки", - "scrobbleFilter": "" + "libraries": "Бібліотеки" }, "helperTexts": { "name": "Змінене ім'я буде відображатися при наступній авторизації", - "libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням", - "scrobbleFilter": "" + "libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням" }, "notifications": { "created": "Користувача створено", @@ -176,8 +171,7 @@ "adminAutoLibraries": "Користувачі-адміністратори автоматично отримують доступ до всіх бібліотек" }, "validation": { - "librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека", - "invalidScrobbleFilter": "" + "librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека" } }, "player": { @@ -200,9 +194,6 @@ "targetFormat": "Цільовий формат", "defaultBitRate": "Швидкість передачі бітів за замовчуванням", "command": "Команда" - }, - "choices": { - "noDefaultBitRate": "" } }, "playlist": { @@ -217,8 +208,7 @@ "songCount": "Пісні", "comment": "Коментар", "sync": "Автоімпорт", - "path": "Імпортувати із", - "starred": "Улюблене" + "path": "Імпортувати із" }, "actions": { "selectPlaylist": "Вибрати список відтворення:", @@ -411,8 +401,7 @@ "requiredHosts": "Обов'язкові хости", "configValidationError": "Перевірка конфігурації зазнала невдачі:", "schemaRenderError": "Неможливо відобразити форму конфігурації. Схема плагіна може бути недійсною.", - "allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання.", - "idHelp": "" + "allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання." }, "placeholders": { "configKey": "ключ", @@ -608,8 +597,7 @@ "coverUploaded": "Обкладинку оновлено", "coverRemoved": "Обкладинка видалена", "coverUploadError": "Помилка завантаження обкладинки", - "coverRemoveError": "Помилка видалення обкладинки", - "metadataRefreshStarted": "" + "coverRemoveError": "Помилка видалення обкладинки" }, "menu": { "library": "Бібліотека", @@ -644,8 +632,7 @@ "multipleLibraries": "%{selected} з %{total} Бібліотеки", "selectLibraries": "Вибір бібліотек", "none": "Відсутня" - }, - "onlyFavourites": "Показати улюблене" + } }, "player": { "playListsText": "Грати по черзі", diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index cae42460b..63ea5cf60 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,8 +22,6 @@ "bitRate": "比特率", "bitDepth": "位深度", "sampleRate": "采样率", - "albumGain": "专辑增益", - "trackGain": "曲目增益", "channels": "声道", "disc": "碟片 %{discNumber}", "discSubtitle": "碟片副标题", @@ -32,7 +30,7 @@ "rating": "评分", "quality": "品质", "bpm": "BPM", - "playDate": "上次播放", + "playDate": "最后一次播放", "createdAt": "加入日期", "grouping": "分组", "mood": "情绪", @@ -93,8 +91,7 @@ "shuffle": "随机播放", "addToPlaylist": "添加到歌单", "download": "下载", - "info": "查看信息", - "refresh": "刷新元数据" + "info": "查看信息" }, "lists": { "all": "全部", @@ -145,7 +142,7 @@ "name": "用户", "fields": { "userName": "用户名", - "isAdmin": "是否为管理员", + "isAdmin": "是否管理员", "lastLoginAt": "上次登录", "lastAccessAt": "上次访问", "updatedAt": "更新于", @@ -156,13 +153,11 @@ "currentPassword": "当前密码", "newPassword": "新密码", "token": "令牌", - "libraries": "媒体库", - "scrobbleFilter": "个性化记录过滤器" + "libraries": "媒体库" }, "helperTexts": { "name": "名称的更改将在下次登录时生效", - "libraries": "为此用户选择指定媒体库,留空则使用默认媒体库", - "scrobbleFilter": "符合这些智能歌单规则的歌曲不会发送到 Last.fm、ListenBrainz 或个性化记录插件。使用与智能歌单相同的 JSON 语法和行为。例如:{\"all\":[{\"lt\":{\"rating\":4}}]}。留空则记录所有播放。本地播放次数不受影响。" + "libraries": "为此用户选择指定媒体库,留空则使用默认媒体库" }, "notifications": { "created": "用户已创建", @@ -170,8 +165,7 @@ "deleted": "用户已删除" }, "validation": { - "librariesRequired": "至少为非管理员用户选择一个媒体库", - "invalidScrobbleFilter": "必须是有效的智能歌单规则。不支持数量限制(limit)、偏移量(offset)和刷新延迟(refresh delay)。" + "librariesRequired": "至少为非管理员用户选择一个媒体库" }, "message": { "listenBrainzToken": "输入您的 ListenBrainz 用户令牌", @@ -188,7 +182,7 @@ "maxBitRate": "最大比特率", "client": "客户端", "userName": "用户名", - "lastSeen": "上次使用", + "lastSeen": "上次浏览", "reportRealPath": "报告真实路径", "scrobbleEnabled": "发送个性化记录到外部服务" } @@ -200,9 +194,6 @@ "targetFormat": "目标格式", "defaultBitRate": "默认比特率", "command": "命令" - }, - "choices": { - "noDefaultBitRate": "无" } }, "playlist": { @@ -217,8 +208,7 @@ "songCount": "歌曲数", "comment": "注释", "sync": "自动导入", - "path": "导入路径", - "starred": "收藏" + "path": "导入路径" }, "actions": { "selectPlaylist": "选择歌单", @@ -400,7 +390,6 @@ "invalidJson": "配置必须是有效的 JSON" }, "messages": { - "idHelp": "由插件文件名派生的插件 ID。在 Agents 等配置选项中引用此插件时使用。", "configHelp": "使用键值对配置插件。如果插件无需配置则留空。", "configValidationError": "配置验证失败:", "schemaRenderError": "无法渲染配置表单。此插件的 schema 定义可能无效。", @@ -409,10 +398,10 @@ "allUsersHelp": "启用时,插件将可以访问所有用户,包括将来创建的。", "noUsers": "未选择用户", "permissionReason": "原因", - "usersRequired": "此插件需要访问用户信息。请选择允许此插件访问的用户,或启用 '允许所有用户'。", + "usersRequired": "此插件需要访问用户信息。请选择允许此插件访问的用户, 或启用 '允许所有用户'。", "allLibrariesHelp": "启用时,插件将可以访问所有媒体库,包括将来创建的。", "noLibraries": "未选择媒体库", - "librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库,或启用 '允许所有媒体库'。", + "librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库, 或启用 '允许所有媒体库'。", "allowWriteAccessHelp": "启用时,插件将可以修改媒体库目录中的文件。默认情况下,插件仅拥有只读权限。", "requiredHosts": "必需的主机" }, @@ -539,7 +528,7 @@ "no_results": "未找到结果", "no_more_results": "页码 %{page} 超出范围,尝试返回上一页", "page_out_of_boundaries": "页码 %{page} 超出范围", - "page_out_from_end": "已经是最后一页", + "page_out_from_end": "已经最后一页", "page_out_from_begin": "已经是第一页", "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", "page_rows_per_page": "每页项目数:", @@ -574,7 +563,6 @@ "coverRemoved": "封面已移除", "coverUploadError": "上传封面时出错", "coverRemoveError": "移除封面时出错", - "metadataRefreshStarted": "正在后台刷新元数据", "note": "注意", "transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。", "transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过从 Web 界面配置转码选项来执行任意命令。建议禁用此选项,并且仅在需要配置转码选项时启用此功能。", @@ -607,7 +595,7 @@ "shareOriginalFormat": "分享原始格式", "shareDialogTitle": "分享 %{resource} '%{name}'", "shareBatchDialogTitle": "分享 %{smart_count} 项 %{resource}", - "shareCopyToClipboard": "复制到剪切板: Ctrl+C,Enter", + "shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter", "shareSuccess": "URL 已复制: %{url}", "shareFailure": "URL 复制失败: %{url}", "downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})", @@ -635,18 +623,17 @@ "lastfmScrobbling": "启用 Last.fm 的个性化记录", "listenBrainzScrobbling": "启用 ListenBrainz 的个性化记录", "replaygain": "回放增益", - "preAmp": "回放增益 - 前置放大 (dB)", + "preAmp": "前置放大器 (dB)", "gain": { - "none": "禁用", - "album": "使用专辑增益", - "track": "使用曲目增益" + "none": "禁用增益", + "album": "使用专辑增益信息", + "track": "使用歌曲增益信息" } } }, "albumList": "专辑", "playlists": "歌单", "sharedPlaylists": "共享的歌单", - "onlyFavourites": "仅显示收藏项", "about": "关于" }, "player": { @@ -679,10 +666,10 @@ "homepage": "主页", "source": "源代码", "featureRequests": "功能需求", - "lastInsightsCollection": "上次的匿名数据收集", + "lastInsightsCollection": "最近的分析收集", "insights": { - "disabled": "已禁用", - "waiting": "等待中" + "disabled": "禁用", + "waiting": "等待" } }, "tabs": { diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 7b8819da3..92b4af3d0 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -38,9 +38,7 @@ "missing": "遺失", "libraryName": "媒體庫", "composer": "作曲者", - "disc": "光碟 %{discNumber}", - "albumGain": "專輯增益", - "trackGain": "曲目增益" + "disc": "光碟 %{discNumber}" }, "actions": { "addToQueue": "加入至播放佇列", @@ -93,8 +91,7 @@ "addToPlaylist": "加入至播放清單", "download": "下載", "info": "取得資訊", - "share": "分享", - "refresh": "重新整理中繼資料" + "share": "分享" }, "lists": { "all": "所有", @@ -156,13 +153,11 @@ "newPassword": "新密碼", "token": "權杖", "lastAccessAt": "上次存取", - "libraries": "媒體庫", - "scrobbleFilter": "紀錄過濾條件" + "libraries": "媒體庫" }, "helperTexts": { "name": "您的名稱會在下次登入時生效", - "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫", - "scrobbleFilter": "符合這些智慧播放清單規則的歌曲將不會傳送至 Last.fm、ListenBrainz 或 Scrobbler 插件。語法與行為皆與智慧播放清單的 JSON 格式相同。範例:{\"all\":[{\"lt\":{\"rating\":4}}]}。若留空則會記錄所有播放紀錄。本機播放次數不受影響。" + "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫" }, "notifications": { "created": "使用者已建立", @@ -176,8 +171,7 @@ "adminAutoLibraries": "管理員預設可存取所有媒體庫" }, "validation": { - "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫", - "invalidScrobbleFilter": "必須是有效的智慧播放清單規則。不支援數量限制、位移及重新整理延遲。" + "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫" } }, "player": { @@ -200,9 +194,6 @@ "targetFormat": "目標格式", "defaultBitRate": "預設位元率", "command": "指令" - }, - "choices": { - "noDefaultBitRate": "無" } }, "playlist": { @@ -217,8 +208,7 @@ "songCount": "歌曲數", "comment": "註解", "sync": "自動匯入", - "path": "匯入來源", - "starred": "收藏" + "path": "匯入來源" }, "actions": { "selectPlaylist": "選取播放清單:", @@ -411,8 +401,7 @@ "requiredHosts": "必要的 Hosts", "configValidationError": "設定驗證失敗:", "schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。", - "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。", - "idHelp": "插件 ID,衍生自其檔案名稱。在 Agents 等設定選項中參照此插件時請使用此 ID。" + "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。" }, "placeholders": { "configKey": "鍵", @@ -608,8 +597,7 @@ "coverUploaded": "已更新封面圖", "coverRemoved": "已移除封面圖", "coverUploadError": "上傳封面圖時發生錯誤", - "coverRemoveError": "移除封面圖時發生錯誤", - "metadataRefreshStarted": "正在背景重新整理中繼資料" + "coverRemoveError": "移除封面圖時發生錯誤" }, "menu": { "library": "媒體庫", @@ -644,8 +632,7 @@ "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫", "selectLibraries": "選取媒體庫", "none": "無" - }, - "onlyFavourites": "僅顯示收藏" + } }, "player": { "playListsText": "播放佇列", @@ -731,4 +718,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} \ No newline at end of file +} diff --git a/resources/mappings.yaml b/resources/mappings.yaml index 294654b6a..16dddd504 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -110,9 +110,7 @@ main: lyrics: # Note, @lyr and wm/lyrics have been removed. Taglib somehow appears to always populate `lyrics:xxx` aliases: [ uslt:description, lyrics, unsyncedlyrics ] - # Generous cap to fit word-timed TTML/Enhanced-LRC karaoke for a full song, - # while still bounding against pathological tags. - maxLength: 1048576 + maxLength: 32768 type: pair # ex: lyrics:eng, lyrics:xxx comment: aliases: [ comm:description, comment, ©cmt, description, icmt ] diff --git a/resources/mime_types.yaml b/resources/mime_types.yaml index 1a963bdf7..f67b26af4 100644 --- a/resources/mime_types.yaml +++ b/resources/mime_types.yaml @@ -10,7 +10,7 @@ types: .ogg: audio/ogg .oga: audio/ogg .opus: audio/ogg - .aac: audio/aac + .aac: audio/mp4 .alac: audio/mp4 .m4a: audio/mp4 .m4b: audio/mp4 @@ -29,7 +29,6 @@ types: .wvp: audio/x-wavpack .tak: audio/tak .mka: audio/x-matroska - .webm: audio/webm # Image .gif: image/gif @@ -38,9 +37,6 @@ types: .webp: image/webp .png: image/png .bmp: image/bmp - .jxl: image/jxl - .heic: image/heic - .heif: image/heif # List of audio formats that are considered lossless lossless: diff --git a/scanner/controller.go b/scanner/controller.go index 1b13c1846..94248ffd0 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -4,42 +4,41 @@ import ( "context" "errors" "fmt" - "slices" - "sync" "sync/atomic" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "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" - "github.com/navidrome/navidrome/utils/singleton" "golang.org/x/time/rate" ) var ( - ErrAlreadyScanning = model.ErrAlreadyScanning + ErrAlreadyScanning = errors.New("already scanning") ) -func New(rootCtx context.Context, ds model.DataStore, broker events.Broker, +func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker, pls playlists.Playlists, m metrics.Metrics) model.Scanner { c := &controller{ rootCtx: rootCtx, ds: ds, + cw: cw, broker: broker, pls: pls, metrics: m, devExternalScanner: conf.Server.DevExternalScanner, } if !c.devExternalScanner { - c.limiter = new(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) + c.limiter = P(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) } return c } @@ -48,7 +47,7 @@ func (s *controller) getScanner() scanner { if s.devExternalScanner { return &scannerExternal{} } - return &scannerImpl{ds: s.ds, pls: s.pls} + return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls} } // CallScan starts an in-process scan of specific library/folder pairs. @@ -65,7 +64,7 @@ func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, progress := make(chan *ProgressInfo, 100) go func() { defer close(progress) - scanner := &scannerImpl{ds: ds, pls: pls} + scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls} scanner.scanFolders(ctx, fullScan, targets, progress) }() return progress, nil @@ -94,8 +93,9 @@ type scanner interface { } type controller struct { - rootCtx context.Context //nolint:containedctx // scanner lifecycle ctx + rootCtx context.Context ds model.DataStore + cw artwork.CacheWarmer broker events.Broker metrics metrics.Metrics pls playlists.Playlists @@ -108,7 +108,7 @@ type controller struct { // getLastScanTime returns the most recent scan time across all libraries func (s *controller) getLastScanTime(ctx context.Context) (time.Time, error) { - libs, err := s.ds.Library().GetAll(ctx, model.QueryOptions{ + libs, err := s.ds.Library(ctx).GetAll(model.QueryOptions{ Sort: "last_scan_at", Order: "desc", Max: 1, @@ -126,9 +126,9 @@ func (s *controller) getLastScanTime(ctx context.Context) (time.Time, error) { // getScanInfo retrieves scan status from the database func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) { - lastErr, _ = s.ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "") - scanType, _ = s.ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") - startTimeStr, _ := s.ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") + lastErr, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "") + scanType, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") + startTimeStr, _ := s.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") if startTimeStr != "" { startTime, err := time.Parse(time.RFC3339, startTimeStr) @@ -185,7 +185,7 @@ func (s *controller) Status(ctx context.Context) (*model.ScannerStatus, error) { } func (s *controller) getCounters(ctx context.Context) (int64, int64, error) { - libs, err := s.ds.Library().GetAll(ctx) + libs, err := s.ds.Library(ctx).GetAll() if err != nil { return 0, 0, fmt.Errorf("library count: %w", err) } @@ -212,16 +212,6 @@ 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) @@ -238,16 +228,7 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ } // Store scan error in database so it can be displayed in the UI if scanError != nil { - _ = s.ds.Property().Put(ctx, 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) - } + _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error()) } // If changes were detected, send a refresh event to all clients if s.changesDetected { @@ -275,73 +256,18 @@ 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 - scanMaintenanceMux sync.Mutex -) +var running atomic.Bool 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, will resume an interrupted full scan, -// or will rescan a library in full because its PID config changed, 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 || library.NeedsPIDRescan() - }) -} - -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().GetAll(ctx) - 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) @@ -388,12 +314,3 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) { s.broker.SendBroadcastMessage(ctx, status) } - -// GetInstance returns the scanner singleton: Status reads the progress counters of the controller -// running the scan, and scheduler, watcher and signal scans do not start from the API's injector. -func GetInstance(rootCtx context.Context, ds model.DataStore, broker events.Broker, - pls playlists.Playlists, m metrics.Metrics) model.Scanner { - return singleton.GetInstance(func() *controller { - return New(rootCtx, ds, broker, pls, m).(*controller) - }) -} diff --git a/scanner/controller_test.go b/scanner/controller_test.go index 974540e32..d60d432b4 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -2,10 +2,10 @@ package scanner_test import ( "context" - "time" "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" "github.com/navidrome/navidrome/core/playlists" @@ -32,11 +32,11 @@ var _ = Describe("Controller", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds.MockedProperty = &tests.MockedPropertyRepo{} - ctrl = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) }) It("includes last scan error", func() { - Expect(ds.Property().Put(ctx, consts.LastScanErrorKey, "boom")).To(Succeed()) + Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "boom")).To(Succeed()) status, err := ctrl.Status(ctx) Expect(err).ToNot(HaveOccurred()) Expect(status.LastError).To(Equal("boom")) @@ -44,8 +44,8 @@ var _ = Describe("Controller", func() { It("includes scan type and error in status", func() { // Set up test data in property repo - Expect(ds.Property().Put(ctx, consts.LastScanErrorKey, "test error")).To(Succeed()) - Expect(ds.Property().Put(ctx, consts.LastScanTypeKey, "full")).To(Succeed()) + Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "test error")).To(Succeed()) + Expect(ds.Property(ctx).Put(consts.LastScanTypeKey, "full")).To(Succeed()) // Get status and verify basic info status, err := ctrl.Status(ctx) @@ -55,58 +55,3 @@ 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() { - pid := model.Library{}.EffectivePID() - libraries := &tests.MockLibraryRepo{} - libraries.SetData(model.Libraries{ - {ID: 1, FullScanInProgress: true, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, - {ID: 2, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, - {ID: 3, LastScanAt: time.Now(), PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, - }) - ds = &tests.MockDataStore{MockedLibrary: libraries} - }) - - It("detects a library that needs a full rescan for a PID change", func() { - targets := []model.ScanTarget{{LibraryID: 3, FolderPath: "."}} - Expect(scanner.EffectiveFullScan(GinkgoT().Context(), ds, false, targets)).To(BeTrue()) - }) - - 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()) - }) -}) - -var _ = Describe("GetInstance", func() { - It("returns the same controller to every caller", func() { - ds := &tests.MockDataStore{} - pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - a := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance()) - b := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance()) - Expect(a).To(BeIdenticalTo(b)) - }) -}) diff --git a/scanner/external.go b/scanner/external.go index 7f573fe3e..29ca90be6 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.String(), - "--cachefolder", conf.Server.CacheFolder.String(), + "--datafolder", conf.Server.DataFolder, + "--cachefolder", conf.Server.CacheFolder, } // Add targets if provided @@ -97,7 +97,8 @@ 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 { - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { _ = 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/folder_entry.go b/scanner/folder_entry.go index e7eef223c..c7cc88ee1 100644 --- a/scanner/folder_entry.go +++ b/scanner/folder_entry.go @@ -10,25 +10,21 @@ import ( "slices" "time" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/chrono" ) -func newFolderEntry(job *scanJob, id, path string, info model.FolderUpdateInfo) *folderEntry { +func newFolderEntry(job *scanJob, id, path string, updTime time.Time, hash string) *folderEntry { f := &folderEntry{ - id: id, - job: job, - path: path, - audioFiles: make(map[string]fs.DirEntry), - imageFiles: make(map[string]fs.DirEntry), - playlistFiles: make(map[string]fs.DirEntry), - albumIDMap: make(map[string]string), - updTime: info.UpdatedAt, - prevHash: info.Hash, - prevImageFiles: info.ImageFiles, - prevImagesUpdatedAt: info.ImagesUpdatedAt, + id: id, + job: job, + path: path, + audioFiles: make(map[string]fs.DirEntry), + imageFiles: make(map[string]fs.DirEntry), + albumIDMap: make(map[string]string), + updTime: updTime, + prevHash: hash, } return f } @@ -42,23 +38,20 @@ type folderEntry struct { updTime time.Time // from DB audioFiles map[string]fs.DirEntry imageFiles map[string]fs.DirEntry - playlistFiles map[string]fs.DirEntry + numPlaylists int numSubFolders int imagesUpdatedAt time.Time prevHash string // Previous hash from DB - // Previous image state from DB, to detect image-only changes - prevImageFiles []string - prevImagesUpdatedAt time.Time - tracks model.MediaFiles - albums model.Albums - albumIDMap map[string]string - artists model.Artists - tags model.TagList - missingTracks []*model.MediaFile + tracks model.MediaFiles + albums model.Albums + albumIDMap map[string]string + artists model.Artists + tags model.TagList + missingTracks []*model.MediaFile } func (f *folderEntry) hasNoFiles() bool { - return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && len(f.playlistFiles) == 0 + return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && f.numPlaylists == 0 } func (f *folderEntry) isEmpty() bool { @@ -76,26 +69,11 @@ func (f *folderEntry) isOutdated() bool { return f.prevHash != f.hash() } -// imagesChanged reports whether the folder's image files differ from the previously persisted -// state, and whether an artist image is involved (present in the old or the new list). -func (f *folderEntry) imagesChanged() (changed, artistImage bool) { - newNames := slices.Sorted(maps.Keys(f.imageFiles)) - prevNames := slices.Sorted(slices.Values(f.prevImageFiles)) - // Both empty also skips the timestamp check, which is noise for image-less folders. - if len(prevNames) == 0 && len(newNames) == 0 { - return false, false - } - if slices.Equal(prevNames, newNames) && f.prevImagesUpdatedAt.Equal(f.imagesUpdatedAt) { - return false, false - } - return true, slices.ContainsFunc(slices.Concat(prevNames, newNames), artwork.IsArtistImageFile) -} - func (f *folderEntry) toFolder() *model.Folder { folder := model.NewFolder(f.job.lib, f.path) folder.NumAudioFiles = len(f.audioFiles) if playlists.InPath(*folder) { - folder.NumPlaylists = len(f.playlistFiles) + folder.NumPlaylists = f.numPlaylists } folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles)) folder.ImagesUpdatedAt = f.imagesUpdatedAt @@ -109,18 +87,16 @@ func (f *folderEntry) hash() string { h, "%s:%d:%d:%s", f.modTime.UTC(), - len(f.playlistFiles), // redundant with the loop below, but dropping it re-hashes every folder + f.numPlaylists, f.numSubFolders, f.imagesUpdatedAt.UTC(), ) - // Sort the keys of audio, image and playlist files to ensure consistent hashing + // Sort the keys of audio and image files to ensure consistent hashing audioKeys := slices.Collect(maps.Keys(f.audioFiles)) slices.Sort(audioKeys) imageKeys := slices.Collect(maps.Keys(f.imageFiles)) slices.Sort(imageKeys) - playlistKeys := slices.Collect(maps.Keys(f.playlistFiles)) - slices.Sort(playlistKeys) // Include audio files with their size and modtime for _, key := range audioKeys { @@ -138,14 +114,5 @@ func (f *folderEntry) hash() string { } } - // Include playlist files, so a content edit is detected even when the folder's - // mtime is preserved (rsync -a) or the playlist is not the newest file. - for _, key := range playlistKeys { - _, _ = io.WriteString(h, key) - if info, err := f.playlistFiles[key].Info(); err == nil { - _, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String()) - } - } - return hex.EncodeToString(h.Sum(nil)) } diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go index 4493f9309..0328c6653 100644 --- a/scanner/folder_entry_test.go +++ b/scanner/folder_entry_test.go @@ -41,14 +41,13 @@ var _ = Describe("folder_entry", func() { Hash: "previous-hash", } - entry := newFolderEntry(job, folderID, path, updateInfo) + entry := newFolderEntry(job, folderID, path, updateInfo.UpdatedAt, updateInfo.Hash) Expect(entry.id).To(Equal(folderID)) Expect(entry.job).To(Equal(job)) Expect(entry.path).To(Equal(path)) Expect(entry.audioFiles).To(BeEmpty()) Expect(entry.imageFiles).To(BeEmpty()) - Expect(entry.playlistFiles).To(BeEmpty()) Expect(entry.albumIDMap).To(BeEmpty()) Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt)) Expect(entry.prevHash).To(Equal(updateInfo.Hash)) @@ -77,7 +76,7 @@ var _ = Describe("folder_entry", func() { BeforeEach(func() { folderID := model.FolderID(lib, path) - entry = newFolderEntry(job, folderID, path, model.FolderUpdateInfo{}) + entry = newFolderEntry(job, folderID, path, time.Time{}, "") }) Describe("hasNoFiles", func() { @@ -96,7 +95,7 @@ var _ = Describe("folder_entry", func() { }) It("returns false when folder has playlists", func() { - entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} + entry.numPlaylists = 1 Expect(entry.hasNoFiles()).To(BeFalse()) }) @@ -108,7 +107,7 @@ var _ = Describe("folder_entry", func() { It("returns false when folder has multiple types of content", func() { entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"} entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"} - entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} + entry.numPlaylists = 2 entry.numSubFolders = 3 Expect(entry.hasNoFiles()).To(BeFalse()) }) @@ -150,11 +149,7 @@ var _ = Describe("folder_entry", func() { "cover.jpg": &fakeDirEntry{name: "cover.jpg"}, "folder.png": &fakeDirEntry{name: "folder.png"}, } - entry.playlistFiles = map[string]fs.DirEntry{ - "list1.m3u": &fakeDirEntry{name: "list1.m3u"}, - "list2.m3u": &fakeDirEntry{name: "list2.m3u"}, - "list3.m3u": &fakeDirEntry{name: "list3.m3u"}, - } + entry.numPlaylists = 3 entry.imagesUpdatedAt = time.Now() }) @@ -205,10 +200,7 @@ var _ = Describe("folder_entry", func() { "z.jpg": &fakeDirEntry{name: "z.jpg"}, "x.png": &fakeDirEntry{name: "x.png"}, } - entry.playlistFiles = map[string]fs.DirEntry{ - "q.m3u": &fakeDirEntry{name: "q.m3u"}, - "p.m3u": &fakeDirEntry{name: "p.m3u"}, - } + entry.numPlaylists = 2 entry.numSubFolders = 3 hash1 := entry.hash() @@ -222,10 +214,6 @@ var _ = Describe("folder_entry", func() { "x.png": &fakeDirEntry{name: "x.png"}, "z.jpg": &fakeDirEntry{name: "z.jpg"}, } - entry.playlistFiles = map[string]fs.DirEntry{ - "p.m3u": &fakeDirEntry{name: "p.m3u"}, - "q.m3u": &fakeDirEntry{name: "q.m3u"}, - } hash2 := entry.hash() Expect(hash1).To(Equal(hash2)) @@ -264,10 +252,10 @@ var _ = Describe("folder_entry", func() { Expect(hash1).ToNot(Equal(hash2)) }) - It("produces different hash when playlist files change", func() { + It("produces different hash when playlist count changes", func() { hash1 := entry.hash() - entry.playlistFiles["new.m3u"] = &fakeDirEntry{name: "new.m3u"} + entry.numPlaylists = 5 hash2 := entry.hash() Expect(hash1).ToNot(Equal(hash2)) @@ -389,58 +377,6 @@ var _ = Describe("folder_entry", func() { Expect(hash1).ToNot(Equal(hash2)) }) - It("produces different hash when playlist file size changes", func() { - baseTime := time.Now() - entry.playlistFiles["list.m3u"] = &fakeDirEntry{ - name: "list.m3u", - fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime}, - } - hash1 := entry.hash() - - entry.playlistFiles["list.m3u"] = &fakeDirEntry{ - name: "list.m3u", - fileInfo: &fakeFileInfo{name: "list.m3u", size: 2000, modTime: baseTime}, - } - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when playlist file modification time changes", func() { - baseTime := time.Now() - entry.playlistFiles["list.m3u"] = &fakeDirEntry{ - name: "list.m3u", - fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime}, - } - hash1 := entry.hash() - - entry.playlistFiles["list.m3u"] = &fakeDirEntry{ - name: "list.m3u", - fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime.Add(1 * time.Hour)}, - } - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when a playlist is renamed", func() { - baseTime := time.Now() - entry.playlistFiles["old.m3u"] = &fakeDirEntry{ - name: "old.m3u", - fileInfo: &fakeFileInfo{name: "old.m3u", size: 1000, modTime: baseTime}, - } - hash1 := entry.hash() - - delete(entry.playlistFiles, "old.m3u") - entry.playlistFiles["new.m3u"] = &fakeDirEntry{ - name: "new.m3u", - fileInfo: &fakeFileInfo{name: "new.m3u", size: 1000, modTime: baseTime}, - } - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - It("produces valid hex-encoded hash", func() { hash := entry.hash() Expect(hash).To(HaveLen(32)) // MD5 hash should be 32 hex characters @@ -485,7 +421,7 @@ var _ = Describe("folder_entry", func() { }) It("returns true when hash has changed", func() { - entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // Change something to change the hash + entry.numPlaylists = 10 // Change something to change the hash Expect(entry.isOutdated()).To(BeTrue()) }) @@ -509,7 +445,7 @@ var _ = Describe("folder_entry", func() { It("returns true when full scan condition is not met but hash changed", func() { entry.updTime = entry.job.lib.LastScanStartedAt.Add(1 * time.Hour) - entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // Change hash + entry.numPlaylists = 10 // Change hash Expect(entry.isOutdated()).To(BeTrue()) }) }) @@ -521,7 +457,7 @@ var _ = Describe("folder_entry", func() { // Create new folder entry folderPath := "music/rock/album" folderID := model.FolderID(lib, folderPath) - entry := newFolderEntry(job, folderID, folderPath, model.FolderUpdateInfo{}) + entry := newFolderEntry(job, folderID, folderPath, time.Time{}, "") // Initially new and has no files Expect(entry.isNew()).To(BeTrue()) diff --git a/scanner/image_changes.go b/scanner/image_changes.go deleted file mode 100644 index 3ea7ce57c..000000000 --- a/scanner/image_changes.go +++ /dev/null @@ -1,112 +0,0 @@ -package scanner - -import ( - "context" - "sync" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -// imageChangedFolder records a folder whose image files changed during the scan, so the -// affected albums/artists can be re-enqueued for artwork resolution at the end of phase 1. -type imageChangedFolder struct { - id string - path string - artistImage bool -} - -// imageChangeCollector gathers those folders per library while phase 1 persists them, then turns -// them into artwork queue items once. -type imageChangeCollector struct { - libs map[int]model.Library - folders map[int][]imageChangedFolder - ds model.DataStore - mutex sync.Mutex -} - -func (c *imageChangeCollector) record(lib model.Library, folder imageChangedFolder) { - c.mutex.Lock() - defer c.mutex.Unlock() - if c.folders == nil { - c.folders = map[int][]imageChangedFolder{} - c.libs = map[int]model.Library{} - } - c.libs[lib.ID] = lib - c.folders[lib.ID] = append(c.folders[lib.ID], folder) -} - -// enqueue is best-effort: failures are logged and never fail the scan. -func (c *imageChangeCollector) enqueue(ctx context.Context) { - c.mutex.Lock() - foldersMap, libsMap := c.folders, c.libs - c.folders, c.libs = nil, nil - c.mutex.Unlock() - - for libID, folders := range foldersMap { - lib := libsMap[libID] - items, err := c.queueItems(ctx, lib, folders) - if err != nil { - log.Warn(ctx, "Scanner: could not map image changes to artwork items", "lib", lib.Name, err) - continue - } - if len(items) == 0 { - continue - } - if err := c.ds.ArtworkQueue().Enqueue(ctx, items...); err != nil { - log.Warn(ctx, "Scanner: could not enqueue artwork for image changes", "lib", lib.Name, err) - continue - } - log.Debug(ctx, "Scanner: Enqueued artwork resolution for image changes", "lib", lib.Name, - "changedFolders", len(folders), "items", len(items)) - } -} - -func (c *imageChangeCollector) queueItems(ctx context.Context, lib model.Library, - folders []imageChangedFolder, -) ([]model.ArtworkQueueItem, error) { - folderIDs := make([]string, len(folders)) - var artistFolderPaths []string - for i, f := range folders { - folderIDs[i] = f.id - if f.artistImage { - artistFolderPaths = append(artistFolderPaths, f.path) - } - } - - var items []model.ArtworkQueueItem - - albumIDs, err := c.ds.MediaFile().GetAlbumIDsByFolder(ctx, lib, folderIDs...) - if err != nil { - return nil, err - } - for _, id := range albumIDs { - items = append(items, scanArtworkItem(model.KindAlbumArtwork, id)) - } - - if len(artistFolderPaths) == 0 { - return items, nil - } - // The resolver climbs to the library root, so the subtree below the folder is the affected set. - // A failure here must not discard the album items already collected. - artistIDs, err := c.ds.Album().GetSoleAlbumArtistIDsInSubtrees(ctx, lib, artistFolderPaths...) - if err != nil { - log.Warn(ctx, "Scanner: could not map image changes to artists", "lib", lib.Name, err) - return items, nil - } - for _, id := range artistIDs { - if id == "" || id == consts.UnknownArtistID || id == consts.VariousArtistsID { - continue - } - items = append(items, scanArtworkItem(model.KindArtistArtwork, id)) - } - return items, nil -} - -func scanArtworkItem(kind model.Kind, id string) model.ArtworkQueueItem { - return model.ArtworkQueueItem{ - ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityScan, - } -} diff --git a/scanner/metadata_old/ffmpeg/ffmpeg.go b/scanner/metadata_old/ffmpeg/ffmpeg.go new file mode 100644 index 000000000..8fc496c02 --- /dev/null +++ b/scanner/metadata_old/ffmpeg/ffmpeg.go @@ -0,0 +1,211 @@ +package ffmpeg + +import ( + "bufio" + "context" + "errors" + "regexp" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/scanner/metadata_old" +) + +const ExtractorID = "ffmpeg" + +type Extractor struct { + ffmpeg ffmpeg.FFmpeg +} + +func (e *Extractor) Parse(files ...string) (map[string]metadata_old.ParsedTags, error) { + output, err := e.ffmpeg.Probe(context.TODO(), files) + if err != nil { + log.Error("Cannot use ffmpeg to extract tags. Aborting", err) + return nil, err + } + fileTags := map[string]metadata_old.ParsedTags{} + if len(output) == 0 { + return fileTags, errors.New("error extracting metadata files") + } + infos := e.parseOutput(output) + for file, info := range infos { + tags, err := e.extractMetadata(file, info) + // Skip files with errors + if err == nil { + fileTags[file] = tags + } + } + return fileTags, nil +} + +func (e *Extractor) CustomMappings() metadata_old.ParsedTags { + return metadata_old.ParsedTags{ + "disc": {"tpa"}, + "has_picture": {"metadata_block_picture"}, + "originaldate": {"tdor"}, + } +} + +func (e *Extractor) Version() string { + return e.ffmpeg.Version() +} + +func (e *Extractor) extractMetadata(filePath, info string) (metadata_old.ParsedTags, error) { + tags := e.parseInfo(info) + if len(tags) == 0 { + log.Trace("Not a media file. Skipping", "filePath", filePath) + return nil, errors.New("not a media file") + } + + return tags, nil +} + +var ( + // Input #0, mp3, from 'groovin.mp3': + inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`) + + // TITLE : Back In Black + tagsRx = regexp.MustCompile(`(?i)^\s{4,6}([\w\s-]+)\s*:(.*)`) + + // : Second comment line + continuationRx = regexp.MustCompile(`(?i)^\s+:(.*)`) + + // Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s` + durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`) + + // Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s + bitRateRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:\d+: Audio:.*, (\d+) kb/s`) + + // Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s + // Stream #0:0: Audio: flac, 44100 Hz, stereo, s16 + // Stream #0:0: Audio: dsd_lsbf_planar, 352800 Hz, stereo, fltp, 5644 kb/s + audioStreamRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:\d+.*: Audio: (.*), (.*) Hz, ([\w.]+),*(.*.,)*`) + + // Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc` + coverRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:.+: (Video):.*`) +) + +func (e *Extractor) parseOutput(output string) map[string]string { + outputs := map[string]string{} + all := inputRegex.FindAllStringSubmatchIndex(output, -1) + for i, loc := range all { + // Filename is the first captured group + file := output[loc[2]:loc[3]] + + // File info is everything from the match, up until the beginning of the next match + info := "" + initial := loc[1] + if i < len(all)-1 { + end := all[i+1][0] - 1 + info = output[initial:end] + } else { + // if this is the last match + info = output[initial:] + } + outputs[file] = info + } + return outputs +} + +func (e *Extractor) parseInfo(info string) map[string][]string { + tags := map[string][]string{} + + reader := strings.NewReader(info) + scanner := bufio.NewScanner(reader) + lastTag := "" + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + continue + } + match := tagsRx.FindStringSubmatch(line) + if len(match) > 0 { + tagName := strings.TrimSpace(strings.ToLower(match[1])) + if tagName != "" { + tagValue := strings.TrimSpace(match[2]) + tags[tagName] = append(tags[tagName], tagValue) + lastTag = tagName + continue + } + } + + if lastTag != "" { + match = continuationRx.FindStringSubmatch(line) + if len(match) > 0 { + if tags[lastTag] == nil { + tags[lastTag] = []string{""} + } + tagValue := tags[lastTag][0] + tags[lastTag][0] = tagValue + "\n" + strings.TrimSpace(match[1]) + continue + } + } + + lastTag = "" + match = coverRx.FindStringSubmatch(line) + if len(match) > 0 { + tags["has_picture"] = []string{"true"} + continue + } + + match = durationRx.FindStringSubmatch(line) + if len(match) > 0 { + tags["duration"] = []string{e.parseDuration(match[1])} + if len(match) > 1 { + tags["bitrate"] = []string{match[2]} + } + continue + } + + match = bitRateRx.FindStringSubmatch(line) + if len(match) > 0 { + tags["bitrate"] = []string{match[1]} + } + + match = audioStreamRx.FindStringSubmatch(line) + if len(match) > 0 { + tags["samplerate"] = []string{match[2]} + tags["channels"] = []string{e.parseChannels(match[3])} + } + } + + comment := tags["comment"] + if len(comment) > 0 && comment[0] == "Cover (front)" { + delete(tags, "comment") + } + + return tags +} + +var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC) + +func (e *Extractor) parseDuration(tag string) string { + d, err := time.Parse("15:04:05", tag) + if err != nil { + return "0" + } + return strconv.FormatFloat(d.Sub(zeroTime).Seconds(), 'f', 2, 32) +} + +func (e *Extractor) parseChannels(tag string) string { + switch tag { + case "mono": + return "1" + case "stereo": + return "2" + case "5.1": + return "6" + case "7.1": + return "8" + default: + return "0" + } +} + +// Inputs will always be absolute paths +func init() { + metadata_old.RegisterExtractor(ExtractorID, &Extractor{ffmpeg: ffmpeg.New()}) +} diff --git a/model/id/id_suite_test.go b/scanner/metadata_old/ffmpeg/ffmpeg_suite_test.go similarity index 69% rename from model/id/id_suite_test.go rename to scanner/metadata_old/ffmpeg/ffmpeg_suite_test.go index e8a41f4e5..815940381 100644 --- a/model/id/id_suite_test.go +++ b/scanner/metadata_old/ffmpeg/ffmpeg_suite_test.go @@ -1,4 +1,4 @@ -package id_test +package ffmpeg import ( "testing" @@ -9,9 +9,9 @@ import ( . "github.com/onsi/gomega" ) -func TestID(t *testing.T) { - tests.Init(t, false) +func TestFFMpeg(t *testing.T) { + tests.Init(t, true) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) - RunSpecs(t, "ID Suite") + RunSpecs(t, "FFMpeg Suite") } diff --git a/scanner/metadata_old/ffmpeg/ffmpeg_test.go b/scanner/metadata_old/ffmpeg/ffmpeg_test.go new file mode 100644 index 000000000..6c7f43a5d --- /dev/null +++ b/scanner/metadata_old/ffmpeg/ffmpeg_test.go @@ -0,0 +1,375 @@ +package ffmpeg + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Extractor", func() { + var e *Extractor + BeforeEach(func() { + e = &Extractor{} + }) + + Context("extractMetadata", func() { + It("extracts MusicBrainz custom tags", func() { + const output = ` +Input #0, ape, from './Capture/02 01 - Symphony No. 5 in C minor, Op. 67 I. Allegro con brio - Ludwig van Beethoven.ape': + Metadata: + ALBUM : Forever Classics + ARTIST : Ludwig van Beethoven + TITLE : Symphony No. 5 in C minor, Op. 67: I. Allegro con brio + MUSICBRAINZ_ALBUMSTATUS: official + MUSICBRAINZ_ALBUMTYPE: album + MusicBrainz_AlbumComment: MP3 + Musicbrainz_Albumid: 71eb5e4a-90e2-4a31-a2d1-a96485fcb667 + musicbrainz_trackid: ffe06940-727a-415a-b608-b7e45737f9d8 + Musicbrainz_Artistid: 1f9df192-a621-4f54-8850-2c5373b7eac9 + Musicbrainz_Albumartistid: 89ad4ac3-39f7-470e-963a-56509c546377 + Musicbrainz_Releasegroupid: 708b1ae1-2d3d-34c7-b764-2732b154f5b6 + musicbrainz_releasetrackid: 6fee2e35-3049-358f-83be-43b36141028b + CatalogNumber : PLD 1201 +` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(SatisfyAll( + HaveKeyWithValue("catalognumber", []string{"PLD 1201"}), + HaveKeyWithValue("musicbrainz_trackid", []string{"ffe06940-727a-415a-b608-b7e45737f9d8"}), + HaveKeyWithValue("musicbrainz_albumid", []string{"71eb5e4a-90e2-4a31-a2d1-a96485fcb667"}), + HaveKeyWithValue("musicbrainz_artistid", []string{"1f9df192-a621-4f54-8850-2c5373b7eac9"}), + HaveKeyWithValue("musicbrainz_albumartistid", []string{"89ad4ac3-39f7-470e-963a-56509c546377"}), + HaveKeyWithValue("musicbrainz_albumtype", []string{"album"}), + HaveKeyWithValue("musicbrainz_albumcomment", []string{"MP3"}), + )) + }) + + It("detects embedded cover art correctly", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': + Metadata: + compilation : 1 + Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s + Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s + Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"})) + }) + + It("detects embedded cover art in ffmpeg 4.4 output", func() { + const output = ` +Input #0, flac, from '/run/media/naomi/Archivio/Musica/Katy Perry/Chained to the Rhythm/01 Katy Perry featuring Skip Marley - Chained to the Rhythm.flac': + Metadata: + ARTIST : Katy Perry featuring Skip Marley + Duration: 00:03:57.91, start: 0.000000, bitrate: 983 kb/s + Stream #0:0: Audio: flac, 44100 Hz, stereo, s16 + Stream #0:1: Video: mjpeg (Baseline), yuvj444p(pc, bt470bg/unknown/unknown), 599x518, 90k tbr, 90k tbn, 90k tbc (attached pic) + Metadata: + comment : Cover (front)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"})) + }) + + It("detects embedded cover art in ogg containers", func() { + const output = ` +Input #0, ogg, from '/Users/deluan/Music/iTunes/iTunes Media/Music/_Testes/Jamaican In New York/01-02 Jamaican In New York (Album Version).opus': + Duration: 00:04:28.69, start: 0.007500, bitrate: 139 kb/s + Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp + Metadata: + ALBUM : Jamaican In New York + metadata_block_picture: AAAAAwAAAAppbWFnZS9qcGVnAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4Id/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQ + TITLE : Jamaican In New York (Album Version)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKey("metadata_block_picture")) + md = md.Map(e.CustomMappings()) + Expect(md).To(HaveKey("has_picture")) + }) + + It("detects embedded cover art in m4a containers", func() { + const output = ` +Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'Putumayo Presents_ Euro Groove/01 Destins et Désirs.m4a': + Metadata: + album : Putumayo Presents: Euro Groove + Duration: 00:05:15.81, start: 0.047889, bitrate: 133 kb/s + Stream #0:0[0x1](und): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 125 kb/s (default) + Metadata: + creation_time : 2008-03-11T21:03:23.000000Z + vendor_id : [0][0][0][0] + Stream #0:1[0x0]: Video: png, rgb24(pc), 350x350, 90k tbr, 90k tbn (attached pic) +` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"})) + }) + + It("gets bitrate from the stream, if available", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': + Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s + Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("bitrate", []string{"192"})) + }) + + It("parses duration with milliseconds", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': + Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("duration", []string{"302.63"})) + }) + + It("parse flac bitrates", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': + Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s + Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) + }) + + It("parse channels from the stream with bitrate", func() { + const output = ` +Input #0, flac, from '/Users/deluan/Music/Music/Media/__/Crazy For You/01-01 Crazy For You.flac': + Metadata: + TITLE : Crazy For You + Duration: 00:04:13.00, start: 0.000000, bitrate: 852 kb/s + Stream #0:0: Audio: flac, 44100 Hz, stereo, s16 + Stream #0:1: Video: mjpeg (Progressive), yuvj444p(pc, bt470bg/unknown/unknown), 600x600, 90k tbr, 90k tbn, 90k tbc (attached pic) +` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("bitrate", []string{"852"})) + }) + + It("parse 7.1 channels from the stream", func() { + const output = ` +Input #0, wav, from '/Users/deluan/Music/Music/Media/_/multichannel/Nums_7dot1_24_48000.wav': + Duration: 00:00:09.05, bitrate: 9216 kb/s + Stream #0:0: Audio: pcm_s24le ([1][0][0][0] / 0x0001), 48000 Hz, 7.1, s32 (24 bit), 9216 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("channels", []string{"8"})) + }) + + It("parse channels from the stream without bitrate", func() { + const output = ` +Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.flac': + Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s + Stream #0:0: Audio: flac, 44100 Hz, stereo, fltp, s32 (24 bit)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) + }) + + It("parse channels from the stream with lang", func() { + const output = ` +Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.m4a': + Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s + Stream #0:0(eng): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 262 kb/s (default)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) + }) + + It("parse channels from the stream with lang 2", func() { + const output = ` +Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.m4a': + Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s + Stream #0:0(eng): Audio: vorbis, 44100 Hz, stereo, fltp, 192 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) + }) + + It("parse sampleRate from the stream", func() { + const output = ` +Input #0, dsf, from '/Users/deluan/Downloads/06-04 Perpetual Change.dsf': + Duration: 00:14:19.46, start: 0.000000, bitrate: 5644 kb/s + Stream #0:0: Audio: dsd_lsbf_planar, 352800 Hz, stereo, fltp, 5644 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("samplerate", []string{"352800"})) + }) + + It("parse sampleRate from the stream", func() { + const output = ` +Input #0, wav, from '/Users/deluan/Music/Music/Media/_/multichannel/Nums_7dot1_24_48000.wav': + Duration: 00:00:09.05, bitrate: 9216 kb/s + Stream #0:0: Audio: pcm_s24le ([1][0][0][0] / 0x0001), 48000 Hz, 7.1, s32 (24 bit), 9216 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("samplerate", []string{"48000"})) + }) + + It("parses stream level tags", func() { + const output = ` +Input #0, ogg, from './01-02 Drive (Teku).opus': + Metadata: + ALBUM : Hot Wheels Acceleracers Soundtrack + Duration: 00:03:37.37, start: 0.007500, bitrate: 135 kb/s + Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp + Metadata: + TITLE : Drive (Teku)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("title", []string{"Drive (Teku)"})) + }) + + It("does not overlap top level tags with the stream level tags", func() { + const output = ` +Input #0, mp3, from 'groovin.mp3': + Metadata: + title : Groovin' (feat. Daniel Sneijers, Susanne Alt) + Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s + Metadata: + title : garbage` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("title", []string{"Groovin' (feat. Daniel Sneijers, Susanne Alt)", "garbage"})) + }) + + It("parses multiline tags", func() { + const outputWithMultilineComment = ` +Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a': + Metadata: + comment : https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/ + : + : Tracklist: + : + : 01. Saara Saara + : 02. Carta Corrente + : 03. X + : 04. Eclipse Lunar + : 05. Vírus de Sírius + : 06. Doktor Fritz + : 07. Wunderbar + : 08. Quarta Dimensão + Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s` + const expectedComment = `https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/ + +Tracklist: + +01. Saara Saara +02. Carta Corrente +03. X +04. Eclipse Lunar +05. Vírus de Sírius +06. Doktor Fritz +07. Wunderbar +08. Quarta Dimensão` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", outputWithMultilineComment) + Expect(md).To(HaveKeyWithValue("comment", []string{expectedComment})) + }) + + It("parses sort tags correctly", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Downloads/椎名林檎 - 加爾基 精液 栗ノ花 - 2003/02 - ドツペルゲンガー.mp3': + Metadata: + title-sort : Dopperugengā + album : 加爾基 精液 栗ノ花 + artist : 椎名林檎 + album_artist : 椎名林檎 + title : ドツペルゲンガー + albumsort : Kalk Samen Kuri No Hana + artist_sort : Shiina, Ringo + ALBUMARTISTSORT : Shiina, Ringo +` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(SatisfyAll( + HaveKeyWithValue("title", []string{"ドツペルゲンガー"}), + HaveKeyWithValue("album", []string{"加爾基 精液 栗ノ花"}), + HaveKeyWithValue("artist", []string{"椎名林檎"}), + HaveKeyWithValue("album_artist", []string{"椎名林檎"}), + HaveKeyWithValue("title-sort", []string{"Dopperugengā"}), + HaveKeyWithValue("albumsort", []string{"Kalk Samen Kuri No Hana"}), + HaveKeyWithValue("artist_sort", []string{"Shiina, Ringo"}), + HaveKeyWithValue("albumartistsort", []string{"Shiina, Ringo"}), + )) + }) + + It("ignores cover comment", func() { + const output = ` +Input #0, mp3, from './Edie Brickell/Picture Perfect Morning/01-01 Tomorrow Comes.mp3': + Metadata: + title : Tomorrow Comes + artist : Edie Brickell + Duration: 00:03:56.12, start: 0.000000, bitrate: 332 kb/s + Stream #0:0: Audio: mp3, 44100 Hz, stereo, s16p, 320 kb/s + Stream #0:1: Video: mjpeg, yuvj420p(pc, bt470bg/unknown/unknown), 1200x1200 [SAR 72:72 DAR 1:1], 90k tbr, 90k tbn, 90k tbc + Metadata: + comment : Cover (front)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).ToNot(HaveKey("comment")) + }) + + It("parses tags with spaces in the name", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hut, to the Projects, to the Mansion/10 - The Struggle (interlude).mp3': + Metadata: + ALBUM ARTIST : Wyclef Jean +` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("album artist", []string{"Wyclef Jean"})) + }) + }) + + It("parses an integer TBPM tag", func() { + const output = ` + Input #0, mp3, from 'tests/fixtures/test.mp3': + Metadata: + TBPM : 123` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("tbpm", []string{"123"})) + }) + + It("parses and rounds a floating point fBPM tag", func() { + const output = ` + Input #0, ogg, from 'tests/fixtures/test.ogg': + Metadata: + FBPM : 141.7` + md, _ := e.extractMetadata("tests/fixtures/test.ogg", output) + Expect(md).To(HaveKeyWithValue("fbpm", []string{"141.7"})) + }) + + It("parses replaygain data correctly", func() { + const output = ` + Input #0, mp3, from 'test.mp3': + Metadata: + REPLAYGAIN_ALBUM_PEAK: 0.9125 + REPLAYGAIN_TRACK_PEAK: 0.4512 + REPLAYGAIN_TRACK_GAIN: -1.48 dB + REPLAYGAIN_ALBUM_GAIN: +3.21518 dB + Side data: + replaygain: track gain - -1.480000, track peak - 0.000011, album gain - 3.215180, album peak - 0.000021, + ` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(SatisfyAll( + HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}), + HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}), + HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}), + HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}), + )) + }) + + It("parses lyrics with language code", func() { + const output = ` + Input #0, mp3, from 'test.mp3': + Metadata: + lyrics-eng : [00:00.00]This is + : [00:02.50]English + lyrics-xxx : [00:00.00]This is + : [00:02.50]unspecified + ` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(SatisfyAll( + HaveKeyWithValue("lyrics-eng", []string{ + "[00:00.00]This is\n[00:02.50]English", + }), + HaveKeyWithValue("lyrics-xxx", []string{ + "[00:00.00]This is\n[00:02.50]unspecified", + }), + )) + }) + + It("parses normal LYRICS tag", func() { + const output = ` + Input #0, mp3, from 'test.mp3': + Metadata: + LYRICS : [00:00.00]This is + : [00:02.50]English + ` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("lyrics", []string{ + "[00:00.00]This is\n[00:02.50]English", + })) + }) +}) diff --git a/scanner/metadata_old/metadata.go b/scanner/metadata_old/metadata.go new file mode 100644 index 000000000..3ccbd8961 --- /dev/null +++ b/scanner/metadata_old/metadata.go @@ -0,0 +1,408 @@ +package metadata_old + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/djherbis/times" + "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +type Extractor interface { + Parse(files ...string) (map[string]ParsedTags, error) + CustomMappings() ParsedTags + Version() string +} + +var extractors = map[string]Extractor{} + +func RegisterExtractor(id string, parser Extractor) { + extractors[id] = parser +} + +func LogExtractors() { + for id, p := range extractors { + log.Debug("Registered metadata extractor", "id", id, "version", p.Version()) + } +} + +func Extract(files ...string) (map[string]Tags, error) { + p, ok := extractors[conf.Server.Scanner.Extractor] + if !ok { + log.Warn("Invalid 'Scanner.Extractor' option. Using default", "requested", conf.Server.Scanner.Extractor, + "validOptions", "ffmpeg,taglib", "default", consts.DefaultScannerExtractor) + p = extractors[consts.DefaultScannerExtractor] + } + + extractedTags, err := p.Parse(files...) + if err != nil { + return nil, err + } + + result := map[string]Tags{} + for filePath, tags := range extractedTags { + fileInfo, err := os.Stat(filePath) + if err != nil { + log.Warn("Error stating file. Skipping", "filePath", filePath, err) + continue + } + + tags = tags.Map(p.CustomMappings()) + result[filePath] = NewTag(filePath, fileInfo, tags) + } + + return result, nil +} + +func NewTag(filePath string, fileInfo os.FileInfo, tags ParsedTags) Tags { + for t, values := range tags { + values = removeDuplicatesAndEmpty(values) + if len(values) == 0 { + delete(tags, t) + continue + } + tags[t] = values + } + return Tags{ + filePath: filePath, + fileInfo: fileInfo, + Tags: tags, + } +} + +func removeDuplicatesAndEmpty(values []string) []string { + encountered := map[string]struct{}{} + empty := true + result := make([]string, 0, len(values)) + for _, v := range values { + if _, ok := encountered[v]; ok { + continue + } + encountered[v] = struct{}{} + empty = empty && v == "" + result = append(result, v) + } + if empty { + return nil + } + return result +} + +type ParsedTags map[string][]string + +func (p ParsedTags) Map(customMappings ParsedTags) ParsedTags { + if customMappings == nil { + return p + } + for tagName, alternatives := range customMappings { + for _, altName := range alternatives { + if altValue, ok := p[altName]; ok { + p[tagName] = append(p[tagName], altValue...) + delete(p, altName) + } + } + } + return p +} + +type Tags struct { + filePath string + fileInfo os.FileInfo + Tags ParsedTags +} + +// Common tags + +func (t Tags) Title() string { return t.getFirstTagValue("title", "sort_name", "titlesort") } +func (t Tags) Album() string { return t.getFirstTagValue("album", "sort_album", "albumsort") } +func (t Tags) Artist() string { return t.getFirstTagValue("artist", "sort_artist", "artistsort") } +func (t Tags) AlbumArtist() string { + return t.getFirstTagValue("album_artist", "album artist", "albumartist") +} +func (t Tags) SortTitle() string { return t.getSortTag("tsot", "title", "name") } +func (t Tags) SortAlbum() string { return t.getSortTag("tsoa", "album") } +func (t Tags) SortArtist() string { return t.getSortTag("tsop", "artist") } +func (t Tags) SortAlbumArtist() string { return t.getSortTag("tso2", "albumartist", "album_artist") } +func (t Tags) Genres() []string { return t.getAllTagValues("genre") } +func (t Tags) Date() (int, string) { return t.getDate("date") } +func (t Tags) OriginalDate() (int, string) { return t.getDate("originaldate") } +func (t Tags) ReleaseDate() (int, string) { return t.getDate("releasedate") } +func (t Tags) Comment() string { return t.getFirstTagValue("comment") } +func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation", "wm/iscompilation") } +func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") } +func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") } +func (t Tags) DiscSubtitle() string { + return t.getFirstTagValue("tsst", "discsubtitle", "setsubtitle") +} +func (t Tags) CatalogNum() string { return t.getFirstTagValue("catalognumber") } +func (t Tags) Bpm() int { return (int)(math.Round(t.getFloat("tbpm", "bpm", "fbpm"))) } +func (t Tags) HasPicture() bool { return t.getFirstTagValue("has_picture") != "" } + +// MusicBrainz Identifiers + +func (t Tags) MbzReleaseTrackID() string { + return t.getMbzID("musicbrainz_releasetrackid", "musicbrainz release track id") +} + +func (t Tags) MbzRecordingID() string { + return t.getMbzID("musicbrainz_trackid", "musicbrainz track id") +} +func (t Tags) MbzAlbumID() string { return t.getMbzID("musicbrainz_albumid", "musicbrainz album id") } +func (t Tags) MbzArtistID() string { + return t.getMbzID("musicbrainz_artistid", "musicbrainz artist id") +} +func (t Tags) MbzAlbumArtistID() string { + return t.getMbzID("musicbrainz_albumartistid", "musicbrainz album artist id") +} +func (t Tags) MbzAlbumType() string { + return t.getFirstTagValue("musicbrainz_albumtype", "musicbrainz album type") +} +func (t Tags) MbzAlbumComment() string { + return t.getFirstTagValue("musicbrainz_albumcomment", "musicbrainz album comment") +} + +// Gain Properties + +func (t Tags) RGAlbumGain() float64 { + return t.getGainValue("replaygain_album_gain", "r128_album_gain") +} +func (t Tags) RGAlbumPeak() float64 { return t.getPeakValue("replaygain_album_peak") } +func (t Tags) RGTrackGain() float64 { + return t.getGainValue("replaygain_track_gain", "r128_track_gain") +} +func (t Tags) RGTrackPeak() float64 { return t.getPeakValue("replaygain_track_peak") } + +// File properties + +func (t Tags) Duration() float32 { return float32(t.getFloat("duration")) } +func (t Tags) SampleRate() int { return t.getInt("samplerate") } +func (t Tags) BitRate() int { return t.getInt("bitrate") } +func (t Tags) Channels() int { return t.getInt("channels") } +func (t Tags) ModificationTime() time.Time { return t.fileInfo.ModTime() } +func (t Tags) Size() int64 { return t.fileInfo.Size() } +func (t Tags) FilePath() string { return t.filePath } +func (t Tags) Suffix() string { return strings.ToLower(strings.TrimPrefix(path.Ext(t.filePath), ".")) } +func (t Tags) BirthTime() time.Time { + if ts := times.Get(t.fileInfo); ts.HasBirthTime() { + return ts.BirthTime() + } + return time.Now() +} + +func (t Tags) Lyrics() string { + lyricList := model.LyricList{} + basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") + + for _, value := range basicLyrics { + lyrics, err := model.ToLyrics("xxx", value) + if err != nil { + log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) + continue + } + + lyricList = append(lyricList, *lyrics) + } + + for tag, value := range t.Tags { + if after, ok := strings.CutPrefix(tag, "lyrics-"); ok { + language := strings.TrimSpace(after) + + if language == "" { + language = "xxx" + } + + for _, text := range value { + lyrics, err := model.ToLyrics(language, text) + if err != nil { + log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) + continue + } + + lyricList = append(lyricList, *lyrics) + } + } + } + + res, err := json.Marshal(lyricList) + if err != nil { + log.Warn("Unexpected error occurred when serializing lyrics", "file", t.filePath, "error", err) + return "" + } + return string(res) +} + +func (t Tags) getGainValue(rgTagName, r128TagName string) float64 { + // Check for ReplayGain first + // ReplayGain is in the form [-]a.bb dB and normalized to -18dB + var tag = t.getFirstTagValue(rgTagName) + if tag != "" { + tag = strings.TrimSpace(strings.Replace(tag, "dB", "", 1)) + var value, err = strconv.ParseFloat(tag, 64) + if err != nil || value == math.Inf(-1) || value == math.Inf(1) { + return 0 + } + return value + } + + // If ReplayGain is not found, check for R128 gain + // R128 gain is a Q7.8 fixed point number normalized to -23dB + tag = t.getFirstTagValue(r128TagName) + if tag != "" { + var iValue, err = strconv.Atoi(tag) + if err != nil { + return 0 + } + // Convert Q7.8 to float + var value = float64(iValue) / 256.0 + // Adding 5 dB to normalize with ReplayGain level + return value + 5 + } + + return 0 +} + +func (t Tags) getPeakValue(tagName string) float64 { + var tag = t.getFirstTagValue(tagName) + var value, err = strconv.ParseFloat(tag, 64) + if err != nil || value == math.Inf(-1) || value == math.Inf(1) { + // A default of 1 for peak value results in no changes + return 1 + } + return value +} + +func (t Tags) getTags(tagNames ...string) []string { + for _, tag := range tagNames { + if v, ok := t.Tags[tag]; ok { + return v + } + } + return nil +} + +func (t Tags) getFirstTagValue(tagNames ...string) string { + ts := t.getTags(tagNames...) + if len(ts) > 0 { + return ts[0] + } + return "" +} + +func (t Tags) getAllTagValues(tagNames ...string) []string { + values := make([]string, 0, len(tagNames)*2) + for _, tag := range tagNames { + if v, ok := t.Tags[tag]; ok { + values = append(values, v...) + } + } + return values +} + +func (t Tags) getSortTag(originalTag string, tagNames ...string) string { + formats := []string{"sort%s", "sort_%s", "sort-%s", "%ssort", "%s_sort", "%s-sort"} + all := make([]string, 1, len(tagNames)*len(formats)+1) + all[0] = originalTag + for _, tag := range tagNames { + for _, format := range formats { + name := fmt.Sprintf(format, tag) + all = append(all, name) + } + } + return t.getFirstTagValue(all...) +} + +var dateRegex = regexp.MustCompile(`([12]\d\d\d)`) + +func (t Tags) getDate(tagNames ...string) (int, string) { + tag := t.getFirstTagValue(tagNames...) + if len(tag) < 4 { + return 0, "" + } + // first get just the year + match := dateRegex.FindStringSubmatch(tag) + if len(match) == 0 { + log.Warn("Error parsing "+tagNames[0]+" field for year", "file", t.filePath, "date", tag) + return 0, "" + } + year, _ := strconv.Atoi(match[1]) + + if len(tag) < 5 { + return year, match[1] + } + + //then try YYYY-MM-DD + if len(tag) > 10 { + tag = tag[:10] + } + layout := "2006-01-02" + _, err := time.Parse(layout, tag) + if err != nil { + layout = "2006-01" + _, err = time.Parse(layout, tag) + if err != nil { + log.Warn("Error parsing "+tagNames[0]+" field for month + day", "file", t.filePath, "date", tag) + return year, match[1] + } + } + return year, tag +} + +func (t Tags) getBool(tagNames ...string) bool { + tag := t.getFirstTagValue(tagNames...) + if tag == "" { + return false + } + i, _ := strconv.Atoi(strings.TrimSpace(tag)) + return i == 1 +} + +func (t Tags) getTuple(tagNames ...string) (int, int) { + tag := t.getFirstTagValue(tagNames...) + if tag == "" { + return 0, 0 + } + tuple := strings.Split(tag, "/") + t1, t2 := 0, 0 + t1, _ = strconv.Atoi(tuple[0]) + if len(tuple) > 1 { + t2, _ = strconv.Atoi(tuple[1]) + } else { + t2tag := t.getFirstTagValue(tagNames[0] + "total") + t2, _ = strconv.Atoi(t2tag) + } + return t1, t2 +} + +func (t Tags) getMbzID(tagNames ...string) string { + tag := t.getFirstTagValue(tagNames...) + if _, err := uuid.Parse(tag); err != nil { + return "" + } + return tag +} + +func (t Tags) getInt(tagNames ...string) int { + tag := t.getFirstTagValue(tagNames...) + i, _ := strconv.Atoi(tag) + return i +} + +func (t Tags) getFloat(tagNames ...string) float64 { + var tag = t.getFirstTagValue(tagNames...) + var value, err = strconv.ParseFloat(tag, 64) + if err != nil { + return 0 + } + return value +} diff --git a/scanner/metadata_old/metadata_internal_test.go b/scanner/metadata_old/metadata_internal_test.go new file mode 100644 index 000000000..2d21e07eb --- /dev/null +++ b/scanner/metadata_old/metadata_internal_test.go @@ -0,0 +1,144 @@ +package metadata_old + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Tags", func() { + DescribeTable("getDate", + func(tag string, expectedYear int, expectedDate string) { + md := &Tags{} + md.Tags = map[string][]string{"date": {tag}} + testYear, testDate := md.Date() + Expect(testYear).To(Equal(expectedYear)) + Expect(testDate).To(Equal(expectedDate)) + }, + Entry(nil, "1985", 1985, "1985"), + Entry(nil, "2002-01", 2002, "2002-01"), + Entry(nil, "1969.06", 1969, "1969"), + Entry(nil, "1980.07.25", 1980, "1980"), + Entry(nil, "2004-00-00", 2004, "2004"), + Entry(nil, "2016-12-31", 2016, "2016-12-31"), + Entry(nil, "2013-May-12", 2013, "2013"), + Entry(nil, "May 12, 2016", 2016, "2016"), + Entry(nil, "01/10/1990", 1990, "1990"), + Entry(nil, "invalid", 0, ""), + ) + + Describe("getMbzID", func() { + It("return a valid MBID", func() { + md := &Tags{} + md.Tags = map[string][]string{ + "musicbrainz_trackid": {"8f84da07-09a0-477b-b216-cc982dabcde1"}, + "musicbrainz_releasetrackid": {"6caf16d3-0b20-3fe6-8020-52e31831bc11"}, + "musicbrainz_albumid": {"f68c985d-f18b-4f4a-b7f0-87837cf3fbf9"}, + "musicbrainz_artistid": {"89ad4ac3-39f7-470e-963a-56509c546377"}, + "musicbrainz_albumartistid": {"ada7a83c-e3e1-40f1-93f9-3e73dbc9298a"}, + } + Expect(md.MbzRecordingID()).To(Equal("8f84da07-09a0-477b-b216-cc982dabcde1")) + Expect(md.MbzReleaseTrackID()).To(Equal("6caf16d3-0b20-3fe6-8020-52e31831bc11")) + Expect(md.MbzAlbumID()).To(Equal("f68c985d-f18b-4f4a-b7f0-87837cf3fbf9")) + Expect(md.MbzArtistID()).To(Equal("89ad4ac3-39f7-470e-963a-56509c546377")) + Expect(md.MbzAlbumArtistID()).To(Equal("ada7a83c-e3e1-40f1-93f9-3e73dbc9298a")) + }) + It("return empty string for invalid MBID", func() { + md := &Tags{} + md.Tags = map[string][]string{ + "musicbrainz_trackid": {"11406732-6"}, + "musicbrainz_albumid": {"11406732"}, + "musicbrainz_artistid": {"200455"}, + "musicbrainz_albumartistid": {"194"}, + } + Expect(md.MbzRecordingID()).To(Equal("")) + Expect(md.MbzAlbumID()).To(Equal("")) + Expect(md.MbzArtistID()).To(Equal("")) + Expect(md.MbzAlbumArtistID()).To(Equal("")) + }) + }) + + Describe("getAllTagValues", func() { + It("returns values from all tag names", func() { + md := &Tags{} + md.Tags = map[string][]string{ + "genre": {"Rock", "Pop", "New Wave"}, + } + + Expect(md.Genres()).To(ConsistOf("Rock", "Pop", "New Wave")) + }) + }) + + Describe("removeDuplicatesAndEmpty", func() { + It("removes duplicates", func() { + md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{ + "genre": []string{"pop", "rock", "pop"}, + "date": []string{"2023-03-01", "2023-03-01"}, + "mood": []string{"happy", "sad"}, + }) + Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"})) + Expect(md.Tags).To(HaveKeyWithValue("date", []string{"2023-03-01"})) + Expect(md.Tags).To(HaveKeyWithValue("mood", []string{"happy", "sad"})) + }) + It("removes empty tags", func() { + md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{ + "genre": []string{"pop", "rock", "pop"}, + "mood": []string{"", ""}, + }) + Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"})) + Expect(md.Tags).ToNot(HaveKey("mood")) + }) + }) + + Describe("BPM", func() { + var t *Tags + BeforeEach(func() { + t = &Tags{Tags: map[string][]string{ + "fbpm": []string{"141.7"}, + }} + }) + + It("rounds a floating point fBPM tag", func() { + Expect(t.Bpm()).To(Equal(142)) + }) + }) + + Describe("ReplayGain", func() { + DescribeTable("getGainValue", + func(tag string, expected float64) { + md := &Tags{} + md.Tags = map[string][]string{"replaygain_track_gain": {tag}} + Expect(md.RGTrackGain()).To(Equal(expected)) + + }, + Entry("0", "0", 0.0), + Entry("1.2dB", "1.2dB", 1.2), + Entry("Infinity", "Infinity", 0.0), + Entry("Invalid value", "INVALID VALUE", 0.0), + ) + DescribeTable("getPeakValue", + func(tag string, expected float64) { + md := &Tags{} + md.Tags = map[string][]string{"replaygain_track_peak": {tag}} + Expect(md.RGTrackPeak()).To(Equal(expected)) + + }, + Entry("0", "0", 0.0), + Entry("0.5", "0.5", 0.5), + Entry("Invalid dB suffix", "0.7dB", 1.0), + Entry("Infinity", "Infinity", 1.0), + Entry("Invalid value", "INVALID VALUE", 1.0), + ) + DescribeTable("getR128GainValue", + func(tag string, expected float64) { + md := &Tags{} + md.Tags = map[string][]string{"r128_track_gain": {tag}} + Expect(md.RGTrackGain()).To(Equal(expected)) + + }, + Entry("0", "0", 5.0), + Entry("-3776", "-3776", -9.75), + Entry("Infinity", "Infinity", 0.0), + Entry("Invalid value", "INVALID VALUE", 0.0), + ) + }) +}) diff --git a/api/api_suite_test.go b/scanner/metadata_old/metadata_suite_test.go similarity index 67% rename from api/api_suite_test.go rename to scanner/metadata_old/metadata_suite_test.go index 62a547b7c..03ec3c847 100644 --- a/api/api_suite_test.go +++ b/scanner/metadata_old/metadata_suite_test.go @@ -1,4 +1,4 @@ -package api_test +package metadata_old import ( "testing" @@ -9,9 +9,9 @@ import ( . "github.com/onsi/gomega" ) -func TestAPI(t *testing.T) { - tests.Init(t, false) +func TestMetadata(t *testing.T) { + tests.Init(t, true) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) - RunSpecs(t, "API Spec Suite") + RunSpecs(t, "Metadata Suite") } diff --git a/scanner/metadata_old/metadata_test.go b/scanner/metadata_old/metadata_test.go new file mode 100644 index 000000000..444bb7fc4 --- /dev/null +++ b/scanner/metadata_old/metadata_test.go @@ -0,0 +1,95 @@ +package metadata_old_test + +import ( + "cmp" + "encoding/json" + "slices" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner/metadata_old" + _ "github.com/navidrome/navidrome/scanner/metadata_old/ffmpeg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Tags", func() { + var zero int64 = 0 + var secondTs int64 = 2500 + + makeLyrics := func(synced bool, lang, secondLine string) model.Lyrics { + lines := []model.Line{ + {Value: "This is"}, + {Value: secondLine}, + } + + if synced { + lines[0].Start = &zero + lines[1].Start = &secondTs + } + + lyrics := model.Lyrics{ + Lang: lang, + Line: lines, + Synced: synced, + } + + return lyrics + } + + sortLyrics := func(lines model.LyricList) model.LyricList { + slices.SortFunc(lines, func(a, b model.Lyrics) int { + langDiff := cmp.Compare(a.Lang, b.Lang) + if langDiff != 0 { + return langDiff + } + return cmp.Compare(a.Line[1].Value, b.Line[1].Value) + }) + + return lines + } + + compareLyrics := func(m metadata_old.Tags, expected model.LyricList) { + lyrics := model.LyricList{} + Expect(json.Unmarshal([]byte(m.Lyrics()), &lyrics)).To(BeNil()) + Expect(sortLyrics(lyrics)).To(Equal(sortLyrics(expected))) + } + + // Only run these tests if FFmpeg is available + FFmpegContext := XContext + if ffmpeg.New().IsAvailable() { + FFmpegContext = Context + } + FFmpegContext("Extract with FFmpeg", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Scanner.Extractor = "ffmpeg" + }) + + DescribeTable("Lyrics test", + func(file string) { + path := "tests/fixtures/" + file + mds, err := metadata_old.Extract(path) + Expect(err).ToNot(HaveOccurred()) + Expect(mds).To(HaveLen(1)) + + m := mds[path] + compareLyrics(m, model.LyricList{ + makeLyrics(true, "eng", "English"), + makeLyrics(true, "xxx", "unspecified"), + }) + }, + + Entry("Parses AIFF file", "test.aiff"), + Entry("Parses MP3 files", "test.mp3"), + // Disabled, because it fails in pipeline + // Entry("Parses WAV files", "test.wav"), + + // FFMPEG behaves very weirdly for multivalued tags for non-ID3 + // Specifically, they are separated by ";, which is indistinguishable + // from other fields + ) + }) +}) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 4edaecacd..38967832c 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -16,6 +16,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/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -25,7 +26,7 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) -func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore) *phaseFolders { +func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer) *phaseFolders { var jobs []*scanJob // Create scan jobs for all libraries @@ -36,34 +37,31 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor targetFolders = state.targets[lib.ID] } - job, err := newScanJob(ctx, ds, lib, state.fullScan, targetFolders) + job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders) if err != nil { log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) state.sendError(err) - state.markFailed(lib.ID) continue } jobs = append(jobs, job) } - walkCtx, stopWalk := context.WithCancelCause(ctx) - return &phaseFolders{jobs: jobs, ctx: ctx, walkCtx: walkCtx, stopWalk: stopWalk, ds: ds, state: state, - imageChanges: &imageChangeCollector{ds: ds}} + return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state} } type scanJob struct { - lib model.Library - fs storage.MusicFS - lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library - targetFolders []string // Specific folders to scan (including all descendants) - prevAlbumPIDConf string // Album PID spec of the last finished scan, only when it differs from the current one - lock sync.Mutex - numFolders atomic.Int64 + lib model.Library + fs storage.MusicFS + cw artwork.CacheWarmer + lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library + targetFolders []string // Specific folders to scan (including all descendants) + lock sync.Mutex + numFolders atomic.Int64 } -func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { +func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { // Get folder updates, optionally filtered to specific target folders - lastUpdates, err := ds.Folder().GetFolderUpdateInfo(ctx, lib, targetFolders...) + lastUpdates, err := ds.Folder(ctx).GetFolderUpdateInfo(lib, targetFolders...) if err != nil { return nil, fmt.Errorf("getting last updates: %w", err) } @@ -79,32 +77,17 @@ func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, full return nil, fmt.Errorf("getting fs for library: %w", err) } - pid := lib.EffectivePID() - if lib.NeedsPIDRescan() { - msg := "Scanner: PID config changed, rescanning library in full" - if len(targetFolders) > 0 { - msg = "Scanner: PID config changed, rescanning target folders in full" - } - log.Info(ctx, msg, "lib", lib.Name, "targetFolders", targetFolders, - "album", pid.Album, "track", pid.Track, "scannedAlbum", lib.ScannedPIDAlbum, "scannedTrack", lib.ScannedPIDTrack) - fullScan = true - } - var prevAlbumPIDConf string - if lib.ScannedPIDAlbum != pid.Album { - prevAlbumPIDConf = lib.ScannedPIDAlbum - } - // Ensure FullScanInProgress reflects the current scan request. // This is important when resuming an interrupted quick scan as a full scan: // the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly. lib.FullScanInProgress = lib.FullScanInProgress || fullScan return &scanJob{ - lib: lib, - fs: fsys, - lastUpdates: lastUpdates, - targetFolders: targetFolders, - prevAlbumPIDConf: prevAlbumPIDConf, + lib: lib, + fs: fsys, + cw: cw, + lastUpdates: lastUpdates, + targetFolders: targetFolders, }, nil } @@ -125,7 +108,7 @@ func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo { func (j *scanJob) createFolderEntry(path string) *folderEntry { id := model.FolderID(j.lib, path) info := j.popLastUpdate(id) - return newFolderEntry(j, id, path, info) + return newFolderEntry(j, id, path, info.UpdatedAt, info.Hash) } // phaseFolders represents the first phase of the scanning process, which is responsible @@ -140,13 +123,11 @@ func (j *scanJob) createFolderEntry(path string) *folderEntry { // The phaseFolders struct implements the phase interface, providing methods to produce // folder entries, process folders, persist changes to the database, and log the results. type phaseFolders struct { - jobs []*scanJob - ds model.DataStore - ctx context.Context //nolint:containedctx // phase runs under a single scan ctx - walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early - stopWalk context.CancelCauseFunc - state *scanState - imageChanges *imageChangeCollector + jobs []*scanJob + ds model.DataStore + ctx context.Context + state *scanState + prevAlbumPIDConf string } func (p *phaseFolders) description() string { @@ -155,19 +136,25 @@ func (p *phaseFolders) description() string { func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { return ppl.NewProducer(func(put func(entry *folderEntry)) error { + var err error + p.prevAlbumPIDConf, err = p.ds.Property(p.ctx).DefaultGet(consts.PIDAlbumKey, "") + if err != nil { + return fmt.Errorf("getting album PID conf: %w", err) + } + // TODO Parallelize multiple job when we have multiple libraries var total int64 var totalChanged int64 for _, job := range p.jobs { - if utils.IsCtxDone(p.walkCtx) { + if utils.IsCtxDone(p.ctx) { break } - outputChan, err := walkDirTree(p.walkCtx, job, job.targetFolders...) + outputChan, err := walkDirTree(p.ctx, job, job.targetFolders...) if err != nil { log.Warn(p.ctx, "Scanner: Error scanning library", "lib", job.lib.Name, err) } - for folder := range pl.ReadOrDone(p.walkCtx, outputChan) { + for folder := range pl.ReadOrDone(p.ctx, outputChan) { job.numFolders.Add(1) p.state.sendProgress(&ProgressInfo{ LibID: job.lib.ID, @@ -180,15 +167,13 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { log.Trace(p.ctx, "Scanner: Checking folder state", " folder", folder.path, "_updTime", folder.updTime, "_modTime", folder.modTime, "_lastScanStartedAt", folder.job.lib.LastScanStartedAt, "numAudioFiles", len(folder.audioFiles), "numImageFiles", len(folder.imageFiles), - "numPlaylists", len(folder.playlistFiles), "numSubfolders", folder.numSubFolders) + "numPlaylists", folder.numPlaylists, "numSubfolders", folder.numSubFolders) // Check if folder is outdated if folder.isOutdated() { - if !folder.job.lib.FullScanInProgress { - // Ancestor folders need a row even with no files of their own: artwork - // resolution climbs them, and an image added later needs a state to diff. - if folder.isEmpty() && folder.isNew() { - log.Trace(p.ctx, "Scanner: Skipping new empty folder", "folder", folder.path, "lib", job.lib.Name) + if !p.state.fullScan { + if folder.hasNoFiles() && folder.isNew() { + log.Trace(p.ctx, "Scanner: Skipping new folder with no files", "folder", folder.path, "lib", job.lib.Name) continue } log.Debug(p.ctx, "Scanner: Detected changes in folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name) @@ -215,20 +200,16 @@ func (p *phaseFolders) measure(entry *folderEntry) func() time.Duration { func (p *phaseFolders) stages() []ppl.Stage[*folderEntry] { return []ppl.Stage[*folderEntry]{ ppl.NewStage(p.processFolder, ppl.Name("process folder"), ppl.Concurrency(conf.Server.DevScannerThreads)), - // persistChanges is not reentrant, so it always has to run with concurrency=1 - ppl.NewStage(p.persistChanges, ppl.Name("persist changes"), ppl.Concurrency(1)), + ppl.NewStage(p.persistChanges, ppl.Name("persist changes")), ppl.NewStage(p.logFolder, ppl.Name("log results")), } } func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) { defer p.measure(entry)() - if err := context.Cause(p.walkCtx); err != nil { - return entry, err - } // Load children mediafiles from DB - cursor, err := p.ds.MediaFile().GetCursor(p.ctx, model.QueryOptions{ + cursor, err := p.ds.MediaFile(p.ctx).GetCursor(model.QueryOptions{ Filters: squirrel.And{squirrel.Eq{"folder_id": entry.id}}, }) if err != nil { @@ -250,7 +231,7 @@ func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) { for afPath, af := range entry.audioFiles { fullPath := path.Join(entry.path, afPath) dbTrack, foundInDB := dbTracks[fullPath] - if !foundInDB || entry.job.lib.FullScanInProgress { + if !foundInDB || p.state.fullScan { filesToImport[fullPath] = dbTrack } else { info, err := af.Info() @@ -300,18 +281,18 @@ func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string } for filePath, info := range allInfo { md := metadata.New(filePath, info) - track := md.ToMediaFile(entry.job.lib, entry.id) + track := md.ToMediaFile(entry.job.lib.ID, entry.id) tracks = append(tracks, track) for _, t := range track.Tags.FlattenAll() { uniqueTags[t.ID] = t } // Keep track of any album ID changes, to reassign annotations later - prevAlbumID := track.AlbumID + prevAlbumID := "" if prev := toImport[filePath]; prev != nil { prevAlbumID = prev.AlbumID - } else if entry.job.prevAlbumPIDConf != "" { - prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf) + } else { + prevAlbumID = md.AlbumID(track, p.prevAlbumPIDConf) } _, ok := entry.albumIDMap[track.AlbumID] if prevAlbumID != track.AlbumID && !ok { @@ -349,136 +330,112 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) defer p.measure(entry)() p.state.changesDetected.Store(true) - ctx := log.NewContext(p.ctx, "folder", entry.path) - err := p.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - return p.persistFolder(ctx, tx, entry) - }, "scanner: persist changes") - if err != nil { - log.Error(ctx, "Scanner: Error persisting changes to DB", err) - p.stopWalk(err) - return entry, err - } + // Collect artwork IDs to pre-cache after the transaction commits + var artworkIDs []model.ArtworkID - // A new folder's albums/artists were enqueued with it; only pre-existing folders need the diff. - if !entry.isNew() { - if changed, artistImage := entry.imagesChanged(); changed { - p.imageChanges.record(entry.job.lib, imageChangedFolder{ - id: entry.id, path: entry.path, artistImage: artistImage, - }) - } - } - return entry, nil -} + err := p.ds.WithTx(func(tx model.DataStore) error { + // Instantiate all repositories just once per folder + folderRepo := tx.Folder(p.ctx) + tagRepo := tx.Tag(p.ctx) + artistRepo := tx.Artist(p.ctx) + libraryRepo := tx.Library(p.ctx) + albumRepo := tx.Album(p.ctx) + mfRepo := tx.MediaFile(p.ctx) -// persistFolder writes the folder in tx. WithTxRetry may rerun it after a rollback. -func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, entry *folderEntry) error { - // Collect artwork queue items for changed albums/artists, enqueued in the same transaction - var queueItems []model.ArtworkQueueItem - // persistAlbum consumes the map, so a rerun needs the original - albumIDMap := maps.Clone(entry.albumIDMap) - - // Instantiate all repositories just once per folder - folderRepo := tx.Folder() - tagRepo := tx.Tag() - artistRepo := tx.Artist() - libraryRepo := tx.Library() - albumRepo := tx.Album() - mfRepo := tx.MediaFile() - - // Save folder to DB - folder := entry.toFolder() - err := folderRepo.Put(ctx, folder) - if err != nil { - return fmt.Errorf("persisting folder: %w", err) - } - - // Save all tags to DB - err = tagRepo.Add(ctx, entry.job.lib.ID, entry.tags...) - if err != nil { - return fmt.Errorf("persisting tags: %w", err) - } - - // 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(ctx, &entry.artists[i], "name", - "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at") - if err != nil { - return fmt.Errorf("persisting artist %q: %w", entry.artists[i].Name, err) - } - err = libraryRepo.AddArtist(ctx, entry.job.lib.ID, entry.artists[i].ID) - if err != nil { - return fmt.Errorf("adding artist %q to library: %w", entry.artists[i].Name, err) - } - if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { - queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID)) - } - } - - // Save all new/modified albums to DB. Their information will be incomplete, but they will be refreshed later - for i := range entry.albums { - err = p.persistAlbum(albumRepo, &entry.albums[i], albumIDMap) + // Save folder to DB + folder := entry.toFolder() + err := folderRepo.Put(folder) if err != nil { + log.Error(p.ctx, "Scanner: Error persisting folder to DB", "folder", entry.path, err) return err } - if entry.albums[i].Name != consts.UnknownAlbum { - queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID)) - } - } - // Save all tracks to DB - for i := range entry.tracks { - err = mfRepo.Put(ctx, &entry.tracks[i]) + // Save all tags to DB + err = tagRepo.Add(entry.job.lib.ID, entry.tags...) if err != nil { - return fmt.Errorf("persisting track %q: %w", entry.tracks[i].Path, err) + log.Error(p.ctx, "Scanner: Error persisting tags to DB", "folder", entry.path, err) + return err + } + + // 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") + if err != nil { + log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err) + return err + } + err = libraryRepo.AddArtist(entry.job.lib.ID, entry.artists[i].ID) + if err != nil { + log.Error(p.ctx, "Scanner: Error adding artist to library", "lib", entry.job.lib.ID, "artist", entry.artists[i].Name, err) + return err + } + if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { + artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID()) + } + } + + // Save all new/modified albums to DB. Their information will be incomplete, but they will be refreshed later + for i := range entry.albums { + err = p.persistAlbum(albumRepo, &entry.albums[i], entry.albumIDMap) + if err != nil { + log.Error(p.ctx, "Scanner: Error persisting album to DB", "folder", entry.path, "album", entry.albums[i], err) + return err + } + if entry.albums[i].Name != consts.UnknownAlbum { + artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID()) + } + } + + // Save all tracks to DB + for i := range entry.tracks { + err = mfRepo.Put(&entry.tracks[i]) + if err != nil { + log.Error(p.ctx, "Scanner: Error persisting mediafile to DB", "folder", entry.path, "track", entry.tracks[i], err) + return err + } + } + + // Mark all missing tracks as not available + if len(entry.missingTracks) > 0 { + err = mfRepo.MarkMissing(true, entry.missingTracks...) + if err != nil { + log.Error(p.ctx, "Scanner: Error marking missing tracks", "folder", entry.path, err) + return err + } + + // Touch all albums that have missing tracks, so they get refreshed in later phases + groupedMissingTracks := slice.ToMap(entry.missingTracks, func(mf *model.MediaFile) (string, struct{}) { + return mf.AlbumID, struct{}{} + }) + albumsToUpdate := slices.Collect(maps.Keys(groupedMissingTracks)) + err = albumRepo.Touch(albumsToUpdate...) + if err != nil { + log.Error(p.ctx, "Scanner: Error touching album", "folder", entry.path, "albums", albumsToUpdate, err) + return err + } + } + return nil + }, "scanner: persist changes") + if err != nil { + log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err) + } + + // Pre-cache artwork after the transaction commits successfully + if err == nil { + for _, artID := range artworkIDs { + entry.job.cw.PreCache(artID) } } - // A re-imported track returns to unresolved so new embedded art is picked up lazily. - if len(entry.tracks) > 0 { - trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID }) - if err := tx.Artwork().DeleteForItems(ctx, model.KindMediaFileArtwork, trackIDs); err != nil { - log.Warn(ctx, "Scanner: could not invalidate media_file artwork", err) - } - } - - // Mark all missing tracks as not available - if len(entry.missingTracks) > 0 { - err = mfRepo.MarkMissing(ctx, true, entry.missingTracks...) - if err != nil { - return fmt.Errorf("marking missing tracks: %w", err) - } - - // Touch all albums that have missing tracks, so they get refreshed in later phases - groupedMissingTracks := slice.ToMap(entry.missingTracks, func(mf *model.MediaFile) (string, struct{}) { - return mf.AlbumID, struct{}{} - }) - albumsToUpdate := slices.Collect(maps.Keys(groupedMissingTracks)) - err = albumRepo.Touch(ctx, albumsToUpdate...) - if err != nil { - return fmt.Errorf("touching albums %v: %w", albumsToUpdate, err) - } - } - - // Enqueue artwork resolution for changed albums/artists. Never fails the scan. - // A full scan re-imports every track, so a re-import is no evidence the art changed. - if len(queueItems) > 0 { - queue := tx.ArtworkQueue() - enqueue := queue.Enqueue - if entry.job.lib.FullScanInProgress { - enqueue = queue.EnqueueIfMissing - } - if err := enqueue(ctx, queueItems...); err != nil { - log.Warn(ctx, "Scanner: could not enqueue artwork resolution", err) - } - } - return nil + return entry, err } // persistAlbum persists the given album to the database, and reassigns annotations from the previous album ID func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album, idMap map[string]string) error { prevID := idMap[a.ID] log.Trace(p.ctx, "Persisting album", "album", a.Name, "albumArtist", a.AlbumArtist, "id", a.ID, "prevID", cmp.Or(prevID, "nil")) - if err := repo.Put(p.ctx, a); err != nil { + if err := repo.Put(a); err != nil { return fmt.Errorf("persisting album %s: %w", a.ID, err) } if prevID == "" { @@ -487,13 +444,13 @@ func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album, // Reassign annotation from previous album to new album log.Trace(p.ctx, "Reassigning album annotations", "from", prevID, "to", a.ID, "album", a.Name) - if err := repo.ReassignAnnotation(p.ctx, prevID, a.ID); err != nil { + if err := repo.ReassignAnnotation(prevID, a.ID); err != nil { log.Warn(p.ctx, "Scanner: Could not reassign annotations", "from", prevID, "to", a.ID, "album", a.Name, err) p.state.sendWarning(fmt.Sprintf("Could not reassign annotations from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err)) } // Keep created_at field from previous instance of the album - if err := repo.CopyAttributes(p.ctx, prevID, a.ID, "created_at"); err != nil { + if err := repo.CopyAttributes(prevID, a.ID, "created_at"); err != nil { // Silently ignore when the previous album is not found if !errors.Is(err, model.ErrNotFound) { log.Warn(p.ctx, "Scanner: Could not copy fields", "from", prevID, "to", a.ID, "album", a.Name, err) @@ -511,39 +468,40 @@ func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) { logCall = log.Trace } logCall(p.ctx, "Scanner: Completed processing folder", - "audioCount", len(entry.audioFiles), "imageCount", len(entry.imageFiles), "plsCount", len(entry.playlistFiles), + "audioCount", len(entry.audioFiles), "imageCount", len(entry.imageFiles), "plsCount", entry.numPlaylists, "elapsed", entry.elapsed.Elapsed(), "tracksMissing", len(entry.missingTracks), "tracksImported", len(entry.tracks), "library", entry.job.lib.Name, consts.Zwsp+"folder", entry.path) return entry, nil } func (p *phaseFolders) finalize(err error) error { - p.stopWalk(nil) - defer p.imageChanges.enqueue(p.ctx) - // A failed phase may not have walked every folder, and unvisited ones must not be marked missing - if err != nil { - return err - } - return p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { + errF := p.ds.WithTx(func(tx model.DataStore) error { for _, job := range p.jobs { // Mark all folders that were not updated as missing if len(job.lastUpdates) == 0 { continue } folderIDs := slices.Collect(maps.Keys(job.lastUpdates)) - if err := tx.Folder().MarkMissing(ctx, true, folderIDs...); err != nil { - return fmt.Errorf("marking missing folders in %s: %w", job.lib.Name, err) + err := tx.Folder(p.ctx).MarkMissing(true, folderIDs...) + if err != nil { + log.Error(p.ctx, "Scanner: Error marking missing folders", "lib", job.lib.Name, err) + return err } - if err := tx.MediaFile().MarkMissingByFolder(ctx, true, folderIDs...); err != nil { - return fmt.Errorf("marking tracks in missing folders in %s: %w", job.lib.Name, err) + err = tx.MediaFile(p.ctx).MarkMissingByFolder(true, folderIDs...) + if err != nil { + log.Error(p.ctx, "Scanner: Error marking tracks in missing folders", "lib", job.lib.Name, err) + return err } // Touch all albums that have missing folders, so they get refreshed in later phases - if _, err := tx.Album().TouchByMissingFolder(ctx); err != nil { - return fmt.Errorf("touching albums with missing folders in %s: %w", job.lib.Name, err) + _, err = tx.Album(p.ctx).TouchByMissingFolder() + if err != nil { + log.Error(p.ctx, "Scanner: Error touching albums with missing folders", "lib", job.lib.Name, err) + return err } } return nil }, "scanner: finalize phaseFolders") + return errors.Join(err, errF) } var _ phase[*folderEntry] = (*phaseFolders)(nil) diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index 945257e47..8c258b833 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -33,7 +33,7 @@ type missingTracks struct { // 4. Updates the database with the new locations of the matched files and removes the old entries. // 5. Logs the results and finalizes the phase by reporting the total number of matched files. type phaseMissingTracks struct { - ctx context.Context //nolint:containedctx // phase runs under a single scan ctx + ctx context.Context ds model.DataStore totalMatched atomic.Uint32 state *scanState @@ -71,7 +71,7 @@ func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error { } for _, lib := range p.state.libraries { log.Debug(p.ctx, "Scanner: Checking missing tracks", "libraryId", lib.ID, "libraryName", lib.Name) - cursor, err := p.ds.MediaFile().GetMissingAndMatching(p.ctx, lib.ID) + cursor, err := p.ds.MediaFile(p.ctx).GetMissingAndMatching(lib.ID) if err != nil { return fmt.Errorf("loading missing tracks for library %s: %w", lib.Name, err) } @@ -232,7 +232,7 @@ func (p *phaseMissingTracks) processCrossLibraryMoves(in *missingTracks) (*missi func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (model.MediaFile, error) { // First tier: Search by MusicBrainz Track ID if available if missing.MbzReleaseTrackID != "" { - matches, err := p.ds.MediaFile().FindRecentFilesByMBZTrackID(p.ctx, missing, missing.CreatedAt) + matches, err := p.ds.MediaFile(p.ctx).FindRecentFilesByMBZTrackID(missing, missing.CreatedAt) if err != nil { log.Error(p.ctx, "Scanner: Error searching for recent files by MBZ Track ID", "mbzTrackID", missing.MbzReleaseTrackID, err) } else { @@ -251,7 +251,7 @@ func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (mod } // Second tier: Search by intrinsic properties (title, size, suffix, etc.) - matches, err := p.ds.MediaFile().FindRecentFilesByProperties(p.ctx, missing, missing.CreatedAt) + matches, err := p.ds.MediaFile(p.ctx).FindRecentFilesByProperties(missing, missing.CreatedAt) if err != nil { log.Error(p.ctx, "Scanner: Error searching for recent files by properties", "missing", missing.Path, err) return model.MediaFile{}, err @@ -273,68 +273,66 @@ func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (mod } func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error { - oldAlbumID := missing.AlbumID - newAlbumID := target.AlbumID - // Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target. - // Claimed before the transaction so a concurrent move skips it, and released if the move fails. - reassignAlbum := oldAlbumID != newAlbumID - if reassignAlbum { - p.annotationMutex.Lock() - reassignAlbum = !p.processedAlbumAnnotations[newAlbumID] - p.processedAlbumAnnotations[newAlbumID] = true - p.annotationMutex.Unlock() - if !reassignAlbum { - log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID) - } - } + return p.ds.WithTx(func(tx model.DataStore) error { + discardedID := target.ID + oldAlbumID := missing.AlbumID + newAlbumID := target.AlbumID - err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - // A rerun must start from the original target, not the one the rolled-back attempt changed - moved := target // Preserve the original created_at from the missing file, so moved tracks // don't appear in "Recently Added" - moved.CreatedAt = missing.CreatedAt + target.CreatedAt = missing.CreatedAt // Update the target media file with the missing file's ID. This effectively "moves" the track // to the new location while keeping its annotations and references intact. - moved.ID = missing.ID - if err := tx.MediaFile().Put(ctx, &moved); err != nil { + target.ID = missing.ID + err := tx.MediaFile(p.ctx).Put(&target) + if err != nil { return fmt.Errorf("update matched track: %w", err) } // Discard the new mediafile row (the one that was moved to) - if err := tx.MediaFile().Delete(ctx, target.ID); err != nil { + err = tx.MediaFile(p.ctx).Delete(discardedID) + if err != nil { return fmt.Errorf("delete discarded track: %w", err) } - if reassignAlbum { - // Reassign direct album annotations (starred, rating) - log.Debug(ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID) - if err := tx.Album().ReassignAnnotation(ctx, oldAlbumID, newAlbumID); err != nil { - log.Warn(ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err) - } + // Handle album annotation reassignment if AlbumID changed + if oldAlbumID != newAlbumID { + // Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target + p.annotationMutex.RLock() + alreadyProcessed := p.processedAlbumAnnotations[newAlbumID] + p.annotationMutex.RUnlock() - // Keep created_at field from previous instance of the album, so moved albums - // don't appear in "Recently Added" - if err := tx.Album().CopyAttributes(ctx, oldAlbumID, newAlbumID, "created_at"); err != nil { - if !errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err) + if !alreadyProcessed { + p.annotationMutex.Lock() + // Double-check pattern to avoid race conditions + if !p.processedAlbumAnnotations[newAlbumID] { + // Reassign direct album annotations (starred, rating) + log.Debug(p.ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID) + if err := tx.Album(p.ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil { + log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err) + } + + // Keep created_at field from previous instance of the album, so moved albums + // don't appear in "Recently Added" + if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil { + if !errors.Is(err, model.ErrNotFound) { + log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err) + } + } + + // Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here + p.processedAlbumAnnotations[newAlbumID] = true } + p.annotationMutex.Unlock() + } else { + log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID) } - // Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here } + + p.state.changesDetected.Store(true) return nil - }, "scanner: move matched track") - if err != nil { - if reassignAlbum { - p.annotationMutex.Lock() - delete(p.processedAlbumAnnotations, newAlbumID) - p.annotationMutex.Unlock() - } - return err - } - p.state.changesDetected.Store(true) - return nil + }) } func (p *phaseMissingTracks) finalize(err error) error { @@ -357,12 +355,7 @@ func (p *phaseMissingTracks) finalize(err error) error { } func (p *phaseMissingTracks) purgeMissing() error { - var deletedCount int64 - err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - var err error - deletedCount, err = tx.MediaFile().DeleteAllMissing(ctx) - return err - }, "scanner: purge missing") + deletedCount, err := p.ds.MediaFile(p.ctx).DeleteAllMissing() if err != nil { return fmt.Errorf("error deleting missing files: %w", err) } diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index f61aa4244..d54ceee40 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -2,8 +2,6 @@ package scanner import ( "context" - "errors" - "maps" "time" "github.com/navidrome/navidrome/conf" @@ -131,8 +129,8 @@ var _ = Describe("phaseMissingTracks", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -144,97 +142,16 @@ var _ = Describe("phaseMissingTracks", func() { Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) Expect(state.changesDetected.Load()).To(BeTrue()) - movedTrack, _ := ds.MediaFile().Get(ctx, "1") + movedTrack, _ := ds.MediaFile(ctx).Get("1") Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) }) - Context("claiming the album annotation reassignment", func() { - var probe *probeTxDS - missingTrack := model.MediaFile{ID: "1", PID: "A", AlbumID: "old-album", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - matchedTrack := model.MediaFile{ID: "2", PID: "A", AlbumID: "new-album", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - BeforeEach(func() { - probe = &probeTxDS{MockDataStore: ds.(*tests.MockDataStore)} - probe.MockedAlbum = tests.CreateMockAlbumRepo() - phase = createPhaseMissingTracks(ctx, state, probe) - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - }) - - It("claims the target album before the transaction, so a concurrent move skips it", func() { - probe.during = func() { - phase.annotationMutex.RLock() - defer phase.annotationMutex.RUnlock() - Expect(phase.processedAlbumAnnotations).To(HaveKeyWithValue("new-album", true)) - } - Expect(phase.moveMatched(matchedTrack, missingTrack)).To(Succeed()) - }) - - It("releases the claim when the move fails, so a later move can reassign", func() { - probe.err = errors.New("boom") - Expect(phase.moveMatched(matchedTrack, missingTrack)).To(MatchError("boom")) - Expect(phase.processedAlbumAnnotations).ToNot(HaveKey("new-album")) - }) - }) - - Context("when the move transaction is rerun after a busy rollback", func() { - var rerunDS *rerunTxDS - BeforeEach(func() { - rerunDS = &rerunTxDS{MockDataStore: ds.(*tests.MockDataStore)} - rerunDS.snapshot = func() func() { - saved := maps.Clone(mr.Data) - return func() { mr.Data = saved } - } - phase = createPhaseMissingTracks(ctx, state, rerunDS) - }) - - It("keeps the moved track", func() { - missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - _, err := phase.processMissingTracks(&missingTracks{ - missing: []model.MediaFile{missingTrack}, - matched: []model.MediaFile{matchedTrack}, - }) - Expect(err).ToNot(HaveOccurred()) - - movedTrack, err := ds.MediaFile().Get(ctx, "1") - Expect(err).ToNot(HaveOccurred()) - Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) - }) - - It("reassigns the album annotations in the attempt that commits", func() { - albumRepo := tests.CreateMockAlbumRepo() - rerunDS.MockedAlbum = albumRepo - restoreTracks := rerunDS.snapshot - rerunDS.snapshot = func() func() { - restore := restoreTracks() - return func() { - restore() - albumRepo.ReassignAnnotationCalls = nil - } - } - missingTrack := model.MediaFile{ID: "1", PID: "A", AlbumID: "old-album", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - matchedTrack := model.MediaFile{ID: "2", PID: "A", AlbumID: "new-album", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - _, err := phase.processMissingTracks(&missingTracks{ - missing: []model.MediaFile{missingTrack}, - matched: []model.MediaFile{matchedTrack}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue("old-album", "new-album")) - }) - }) - It("should move the matched track when the missing track has the same tags and filename", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "path1.flac", Tags: model.Tags{"title": []string{"title1"}}, Size: 200} - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -246,7 +163,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) Expect(state.changesDetected.Load()).To(BeTrue()) - movedTrack, _ := ds.MediaFile().Get(ctx, "1") + movedTrack, _ := ds.MediaFile(ctx).Get("1") Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) Expect(movedTrack.Size).To(Equal(matchedTrack.Size)) }) @@ -255,8 +172,8 @@ var _ = Describe("phaseMissingTracks", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.flac", Tags: model.Tags{"title": []string{"different title"}}, Size: 200} - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -268,7 +185,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) Expect(state.changesDetected.Load()).To(BeTrue()) - movedTrack, _ := ds.MediaFile().Get(ctx, "1") + movedTrack, _ := ds.MediaFile(ctx).Get("1") Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) Expect(movedTrack.Size).To(Equal(matchedTrack.Size)) }) @@ -278,9 +195,9 @@ var _ = Describe("phaseMissingTracks", func() { matchedEquivalent := model.MediaFile{ID: "2", PID: "A", Path: "dir1/file1.flac", Tags: model.Tags{"title": []string{"title1"}}, Size: 200} matchedExact := model.MediaFile{ID: "3", PID: "A", Path: "dir2/file2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedEquivalent) - _ = ds.MediaFile().Put(ctx, &matchedExact) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedEquivalent) + _ = ds.MediaFile(ctx).Put(&matchedExact) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -293,7 +210,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) Expect(state.changesDetected.Load()).To(BeTrue()) - movedTrack, _ := ds.MediaFile().Get(ctx, "1") + movedTrack, _ := ds.MediaFile(ctx).Get("1") Expect(movedTrack.Path).To(Equal(matchedExact.Path)) Expect(movedTrack.Size).To(Equal(matchedExact.Size)) }) @@ -303,9 +220,9 @@ var _ = Describe("phaseMissingTracks", func() { matched1 := model.MediaFile{ID: "2", PID: "A", Path: "dir1/file2.flac", Title: "another title", Size: 200} matched2 := model.MediaFile{ID: "3", PID: "A", Path: "dir2/file3.mp3", Title: "different title", Size: 100} - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matched1) - _ = ds.MediaFile().Put(ctx, &matched2) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matched1) + _ = ds.MediaFile(ctx).Put(&matched2) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -318,7 +235,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeFalse()) // The missing track should still be the same - movedTrack, _ := ds.MediaFile().Get(ctx, "1") + movedTrack, _ := ds.MediaFile(ctx).Get("1") Expect(movedTrack.Path).To(Equal(missingTrack.Path)) Expect(movedTrack.Title).To(Equal(missingTrack.Title)) Expect(movedTrack.Size).To(Equal(missingTrack.Size)) @@ -333,9 +250,9 @@ var _ = Describe("phaseMissingTracks", func() { missingTrack2 := model.MediaFile{ID: "2", PID: "A", Path: "old_dir2/song.mp3", Title: "title1", Size: 100} matchedTrack := model.MediaFile{ID: "3", PID: "A", Path: "new_dir/song.mp3", Title: "title1", Size: 200} - _ = ds.MediaFile().Put(ctx, &missingTrack1) - _ = ds.MediaFile().Put(ctx, &missingTrack2) - _ = ds.MediaFile().Put(ctx, &matchedTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack1) + _ = ds.MediaFile(ctx).Put(&missingTrack2) + _ = ds.MediaFile(ctx).Put(&matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack1, missingTrack2}, @@ -349,11 +266,11 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeTrue()) // The matched track should have been consumed by the first missing track - movedTrack, _ := ds.MediaFile().Get(ctx, "1") + movedTrack, _ := ds.MediaFile(ctx).Get("1") Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) // The second missing track should remain unchanged - unmatchedTrack, _ := ds.MediaFile().Get(ctx, "2") + unmatchedTrack, _ := ds.MediaFile(ctx).Get("2") Expect(unmatchedTrack.Path).To(Equal(missingTrack2.Path)) }) @@ -361,8 +278,8 @@ var _ = Describe("phaseMissingTracks", func() { missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}} matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}} - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -370,7 +287,7 @@ var _ = Describe("phaseMissingTracks", func() { } // Simulate an error when moving the matched track by deleting the track from the DB - _ = ds.MediaFile().Delete(ctx, "2") + _ = ds.MediaFile(ctx).Delete("2") _, err := phase.processMissingTracks(in) Expect(err).To(HaveOccurred()) @@ -394,7 +311,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'always'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways - mr.SetCountAll(3) + mr.CountAllValue = 3 mr.DeleteAllMissingValue = 3 }) It("should purge missing files", func() { @@ -408,7 +325,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'full'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull - mr.SetCountAll(2) + mr.CountAllValue = 2 mr.DeleteAllMissingValue = 2 }) It("should not purge missing files if not a full scan", func() { @@ -429,7 +346,7 @@ var _ = Describe("phaseMissingTracks", func() { When("PurgeMissing is 'never'", func() { BeforeEach(func() { conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever - mr.SetCountAll(1) + mr.CountAllValue = 1 mr.DeleteAllMissingValue = 1 }) It("should not purge missing files", func() { @@ -514,8 +431,8 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-10 * time.Minute), } - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &movedTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&movedTrack) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -529,7 +446,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeTrue()) // Verify the move was performed - updatedTrack, _ := ds.MediaFile().Get(ctx, "missing1") + updatedTrack, _ := ds.MediaFile(ctx).Get("missing1") Expect(updatedTrack.Path).To(Equal("/lib2/track.mp3")) Expect(updatedTrack.LibraryID).To(Equal(2)) }) @@ -566,8 +483,8 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-10 * time.Minute), } - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &movedTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&movedTrack) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -581,7 +498,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeTrue()) // Verify the move was performed - updatedTrack, _ := ds.MediaFile().Get(ctx, "missing2") + updatedTrack, _ := ds.MediaFile(ctx).Get("missing2") Expect(updatedTrack.Path).To(Equal("/lib2/track2.flac")) Expect(updatedTrack.LibraryID).To(Equal(2)) }) @@ -612,8 +529,8 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-10 * time.Minute), } - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &sameLibTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&sameLibTrack) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -670,9 +587,9 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-5 * time.Minute), } - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &mbzTrack) - _ = ds.MediaFile().Put(ctx, &intrinsicTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&mbzTrack) + _ = ds.MediaFile(ctx).Put(&intrinsicTrack) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -686,7 +603,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeTrue()) // Verify the MBZ track was chosen (not the intrinsic one) - updatedTrack, _ := ds.MediaFile().Get(ctx, "missing4") + updatedTrack, _ := ds.MediaFile(ctx).Get("missing4") Expect(updatedTrack.Path).To(Equal("/lib2/track4.mp3")) Expect(updatedTrack.LibraryID).To(Equal(2)) }) @@ -718,8 +635,8 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-10 * time.Minute), } - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &equivalentTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&equivalentTrack) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -733,7 +650,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeTrue()) // Verify the equivalent match was accepted - updatedTrack, _ := ds.MediaFile().Get(ctx, "missing5") + updatedTrack, _ := ds.MediaFile(ctx).Get("missing5") Expect(updatedTrack.Path).To(Equal("/lib2/different/track5.mp3")) Expect(updatedTrack.LibraryID).To(Equal(2)) }) @@ -788,9 +705,9 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: scanStartTime.Add(-5 * time.Minute), } - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &match1) - _ = ds.MediaFile().Put(ctx, &match2) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&match1) + _ = ds.MediaFile(ctx).Put(&match2) in := &missingTracks{ lib: model.Library{ID: 1, Name: "Library 1"}, @@ -804,7 +721,7 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeFalse()) // Verify no move was performed - unchangedTrack, _ := ds.MediaFile().Get(ctx, "missing6") + unchangedTrack, _ := ds.MediaFile(ctx).Get("missing6") Expect(unchangedTrack.Path).To(Equal("/lib1/track6.mp3")) Expect(unchangedTrack.LibraryID).To(Equal(1)) }) @@ -844,7 +761,7 @@ var _ = Describe("phaseMissingTracks", func() { var albumRepo *tests.MockAlbumRepo BeforeEach(func() { - albumRepo = ds.Album().(*tests.MockAlbumRepo) + albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) albumRepo.ReassignAnnotationCalls = make(map[string]string) albumRepo.CopyAttributesCalls = make(map[string]string) }) @@ -868,8 +785,8 @@ var _ = Describe("phaseMissingTracks", func() { Size: 100, } - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) in := &missingTracks{ missing: []model.MediaFile{missingTrack}, @@ -879,7 +796,7 @@ var _ = Describe("phaseMissingTracks", func() { _, err := phase.processMissingTracks(in) Expect(err).ToNot(HaveOccurred()) - movedTrack, _ := ds.MediaFile().Get(ctx, "1") + movedTrack, _ := ds.MediaFile(ctx).Get("1") Expect(movedTrack.Path).To(Equal("new/song.mp3")) Expect(movedTrack.CreatedAt).To(Equal(originalTime)) }) @@ -905,21 +822,21 @@ var _ = Describe("phaseMissingTracks", func() { {ID: "new-album", LibraryID: 2, CreatedAt: time.Now()}, }) - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) err := phase.moveMatched(matchedTrack, missingTrack) Expect(err).ToNot(HaveOccurred()) // Track's created_at should be preserved from the missing file - movedTrack, _ := ds.MediaFile().Get(ctx, "missing-ca") + movedTrack, _ := ds.MediaFile(ctx).Get("missing-ca") Expect(movedTrack.CreatedAt).To(Equal(originalTime)) // Album's created_at should be copied from old to new Expect(albumRepo.CopyAttributesCalls).To(HaveKeyWithValue("old-album", "new-album")) // Verify the new album's CreatedAt was actually updated - newAlbum, err := albumRepo.Get(ctx, "new-album") + newAlbum, err := albumRepo.Get("new-album") Expect(err).ToNot(HaveOccurred()) Expect(newAlbum.CreatedAt).To(Equal(originalTime)) }) @@ -939,14 +856,14 @@ var _ = Describe("phaseMissingTracks", func() { CreatedAt: time.Now(), } - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) err := phase.moveMatched(matchedTrack, missingTrack) Expect(err).ToNot(HaveOccurred()) // Track's created_at should still be preserved - movedTrack, _ := ds.MediaFile().Get(ctx, "missing-same") + movedTrack, _ := ds.MediaFile(ctx).Get("missing-same") Expect(movedTrack.CreatedAt).To(Equal(originalTime)) // CopyAttributes should NOT have been called (same album) @@ -964,7 +881,7 @@ var _ = Describe("phaseMissingTracks", func() { ) BeforeEach(func() { - albumRepo = ds.Album().(*tests.MockAlbumRepo) + albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) albumRepo.ReassignAnnotationCalls = make(map[string]string) oldAlbumID = "old-album-id" @@ -999,8 +916,8 @@ var _ = Describe("phaseMissingTracks", func() { } // Store both tracks in the database - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) + _ = ds.MediaFile(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) }) When("album ID changes during cross-library move", func() { @@ -1033,41 +950,10 @@ var _ = Describe("phaseMissingTracks", func() { Expect(err).ToNot(HaveOccurred()) // Verify that the track was still moved (ID should be updated) - movedTrack, err := ds.MediaFile().Get(ctx, missingTrack.ID) + movedTrack, err := ds.MediaFile(ctx).Get(missingTrack.ID) Expect(err).ToNot(HaveOccurred()) Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) }) }) }) }) - -// rerunTxDS runs every WithTxRetry block twice, as a retry after a rolled-back busy attempt would. -// The mock is not transactional, so snapshot returns the function that plays the rollback. -type rerunTxDS struct { - *tests.MockDataStore - snapshot func() (rollback func()) -} - -func (d *rerunTxDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, _ ...string) error { - rollback := d.snapshot() - _ = block(ctx, d.MockDataStore) - rollback() - return block(ctx, d.MockDataStore) -} - -// probeTxDS runs a hook inside each WithTxRetry block, and can fail the transaction after it. -type probeTxDS struct { - *tests.MockDataStore - during func() - err error -} - -func (d *probeTxDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, _ ...string) error { - if err := block(ctx, d.MockDataStore); err != nil { - return err - } - if d.during != nil { - d.during() - } - return d.err -} diff --git a/scanner/phase_3_refresh_albums.go b/scanner/phase_3_refresh_albums.go index 58ad9cb80..33e0fed01 100644 --- a/scanner/phase_3_refresh_albums.go +++ b/scanner/phase_3_refresh_albums.go @@ -26,7 +26,7 @@ import ( // 5. As a last step, it refreshes the artist statistics to reflect the changes type phaseRefreshAlbums struct { ds model.DataStore - ctx context.Context //nolint:containedctx // phase runs under a single scan ctx + ctx context.Context refreshed atomic.Uint32 skipped atomic.Uint32 state *scanState @@ -47,7 +47,7 @@ func (p *phaseRefreshAlbums) producer() ppl.Producer[*model.Album] { func (p *phaseRefreshAlbums) produce(put func(album *model.Album)) error { count := 0 for _, lib := range p.state.libraries { - cursor, err := p.ds.Album().GetTouchedAlbums(p.ctx, lib.ID) + cursor, err := p.ds.Album(p.ctx).GetTouchedAlbums(lib.ID) if err != nil { return fmt.Errorf("loading touched albums: %w", err) } @@ -76,7 +76,7 @@ func (p *phaseRefreshAlbums) stages() []ppl.Stage[*model.Album] { } func (p *phaseRefreshAlbums) filterUnmodified(album *model.Album) (*model.Album, error) { - mfs, err := p.ds.MediaFile().GetAll(p.ctx, model.QueryOptions{Filters: squirrel.Eq{"album_id": album.ID}}) + mfs, err := p.ds.MediaFile(p.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": album.ID}}) if err != nil { log.Error(p.ctx, "Error loading media files for album", "album_id", album.ID, err) return nil, err @@ -103,9 +103,7 @@ func (p *phaseRefreshAlbums) refreshAlbum(album *model.Album) (*model.Album, err return nil, nil } start := time.Now() - err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Album().Put(ctx, album) - }, "scanner: refresh album") + err := p.ds.Album(p.ctx).Put(album) log.Debug(p.ctx, "Scanner: refreshing album", "album_id", album.ID, "name", album.Name, "songCount", album.SongCount, "elapsed", time.Since(start), err) if err != nil { return nil, fmt.Errorf("refreshing album %s: %w", album.ID, err) @@ -132,12 +130,7 @@ func (p *phaseRefreshAlbums) finalize(err error) error { } // Refresh album annotations start := time.Now() - var cnt int64 - err = p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - var txErr error - cnt, txErr = tx.Album().RefreshPlayCounts(ctx) - return txErr - }, "scanner: refresh album play counts") + cnt, err := p.ds.Album(p.ctx).RefreshPlayCounts() if err != nil { return fmt.Errorf("refreshing album annotations: %w", err) } @@ -145,11 +138,7 @@ func (p *phaseRefreshAlbums) finalize(err error) error { // Refresh artist annotations start = time.Now() - err = p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - var txErr error - cnt, txErr = tx.Artist().RefreshPlayCounts(ctx) - return txErr - }, "scanner: refresh artist play counts") + cnt, err = p.ds.Artist(p.ctx).RefreshPlayCounts() if err != nil { return fmt.Errorf("refreshing artist annotations: %w", err) } diff --git a/scanner/phase_3_refresh_albums_test.go b/scanner/phase_3_refresh_albums_test.go index 2743da070..1f0baf428 100644 --- a/scanner/phase_3_refresh_albums_test.go +++ b/scanner/phase_3_refresh_albums_test.go @@ -104,7 +104,7 @@ var _ = Describe("phaseRefreshAlbums", func() { Describe("refreshAlbum", func() { It("refreshes the album in the database", func() { - Expect(albumRepo.CountAll(ctx)).To(Equal(int64(0))) + Expect(albumRepo.CountAll()).To(Equal(int64(0))) album := &model.Album{ID: "album1", Name: "Album 1"} result, err := phase.refreshAlbum(album) @@ -112,7 +112,7 @@ var _ = Describe("phaseRefreshAlbums", func() { Expect(result).ToNot(BeNil()) Expect(result.ID).To(Equal("album1")) - savedAlbum, err := albumRepo.Get(ctx, "album1") + savedAlbum, err := albumRepo.Get("album1") Expect(err).ToNot(HaveOccurred()) Expect(savedAlbum).ToNot(BeNil()) diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index bb67c1ba3..d52743966 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -2,7 +2,6 @@ package scanner import ( "context" - "errors" "fmt" "os" "strings" @@ -11,7 +10,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" "github.com/navidrome/navidrome/model" @@ -19,20 +18,21 @@ import ( ) type phasePlaylists struct { - ctx context.Context //nolint:containedctx // phase runs under a single scan ctx - scanState *scanState - ds model.DataStore - pls playlists.Playlists - refreshed atomic.Uint32 - pendingImport bool + ctx context.Context + scanState *scanState + ds model.DataStore + pls playlists.Playlists + cw artwork.CacheWarmer + refreshed atomic.Uint32 } -func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists) *phasePlaylists { +func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists { return &phasePlaylists{ ctx: ctx, scanState: scanState, ds: ds, pls: pls, + cw: cw, } } @@ -49,41 +49,22 @@ 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 } - - // 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().FindFirstAdmin(p.ctx) - 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().GetAllWithPlaylists(p.ctx) - } else { - cursor, err = p.ds.Folder().GetTouchedWithPlaylists(p.ctx) - } - if err != nil { - return fmt.Errorf("loading folders with playlists: %w", err) + 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 } count := 0 - log.Debug(p.ctx, "Scanner: Checking playlists that may need refresh", "pendingImport", p.pendingImport) + 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") for folder, err := range cursor { if err != nil { - return fmt.Errorf("loading folder with playlists: %w", err) + return fmt.Errorf("loading touched folder: %w", err) } count++ put(&folder) @@ -97,26 +78,6 @@ 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 { - err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Property().Put(ctx, consts.PlaylistsImportPendingFlagKey, "1") - }, "scanner: defer playlist import") - if 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().DefaultGet(p.ctx, 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)), @@ -148,11 +109,7 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. } else { log.Debug("Scanner: Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started)) } - item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: pls.ID, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityScan} - if err := p.ds.ArtworkQueue().Enqueue(p.ctx, item); err != nil { - log.Warn(p.ctx, "Scanner: could not enqueue playlist artwork", "id", pls.ID, err) - } + p.cw.PreCache(pls.CoverArtID()) p.refreshed.Add(1) } return folder, nil @@ -166,11 +123,6 @@ func (p *phasePlaylists) finalize(err error) error { } else { p.scanState.changesDetected.Store(true) } - if p.pendingImport && err == nil { - if derr := p.ds.Property().Delete(p.ctx, 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 303af338f..0e01a7549 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -9,9 +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" @@ -26,28 +27,22 @@ var _ = Describe("phasePlaylists", func() { folderRepo *mockFolderRepository ds *tests.MockDataStore pls *mockPlaylists + 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(ctx, &model.User{ID: "123", UserName: "admin", IsAdmin: true})).To(Succeed()) - propRepo = &tests.MockedPropertyRepo{} ds = &tests.MockDataStore{ - MockedFolder: folderRepo, - MockedUser: userRepo, - MockedProperty: propRepo, + MockedFolder: folderRepo, } pls = &mockPlaylists{} + cw = artwork.NoopCacheWarmer() state = &scanState{} - phase = createPhasePlaylists(ctx, state, ds, pls) + phase = createPhasePlaylists(ctx, state, ds, pls, cw) }) Describe("description", func() { @@ -89,81 +84,6 @@ 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(ctx, 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(ctx, 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(ctx, 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(ctx, consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) - phase.pendingImport = true - - Expect(phase.finalize(nil)).To(Succeed()) - - _, err := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey) - Expect(err).To(HaveOccurred()) // deleted - }) - - It("keeps the pending flag when the import failed", func() { - Expect(propRepo.Put(ctx, consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) - phase.pendingImport = true - - Expect(phase.finalize(errors.New("boom"))).To(HaveOccurred()) - - v, _ := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey) - Expect(v).To(Equal("1")) - }) }) Describe("processPlaylistsInFolder", func() { @@ -190,29 +110,6 @@ var _ = Describe("phasePlaylists", func() { Expect(phase.refreshed.Load()).To(Equal(uint32(2))) }) - It("enqueues artwork resolution for the imported playlist", func() { - libPath := GinkgoT().TempDir() - folder := &model.Folder{LibraryPath: libPath, Path: "path/to", Name: "folder"} - _ = os.MkdirAll(folder.AbsolutePath(), 0755) - - file1 := filepath.Join(folder.AbsolutePath(), "playlist1.m3u") - _ = os.WriteFile(file1, []byte{}, 0600) - - pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). - Return(&model.Playlist{ID: "pl1"}, nil) - - _, err := phase.processPlaylistsInFolder(folder) - Expect(err).ToNot(HaveOccurred()) - - queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "pl"), - HaveField("ItemID", "pl1"), - HaveField("Priority", model.ArtworkPriorityScan), - ))) - }) - It("reports an error if there is an error reading files", func() { tests.SkipOnWindows("relies on Unix /etc filesystem") progress := make(chan *ProgressInfo) @@ -244,13 +141,12 @@ func (p *mockPlaylists) ImportFromFolder(ctx context.Context, folder *model.Fold type mockFolderRepository struct { model.FolderRepository - data map[*model.Folder]error - allData map[*model.Folder]error + data map[*model.Folder]error } -func cursorFromData(data map[*model.Folder]error) model.FolderCursor { +func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { return func(yield func(model.Folder, error) bool) { - for folder, err := range data { + for folder, err := range f.data { if err != nil { if !yield(model.Folder{}, err) { return @@ -261,21 +157,9 @@ func cursorFromData(data map[*model.Folder]error) model.FolderCursor { return } } - } -} - -func (f *mockFolderRepository) GetTouchedWithPlaylists(context.Context) (model.FolderCursor, error) { - return cursorFromData(f.data), nil -} - -func (f *mockFolderRepository) GetAllWithPlaylists(context.Context) (model.FolderCursor, error) { - return cursorFromData(f.allData), nil + }, 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 305c443f4..871b0c696 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -9,8 +9,11 @@ import ( "time" 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/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/run" @@ -19,6 +22,7 @@ import ( type scannerImpl struct { ds model.DataStore + cw artwork.CacheWarmer pls playlists.Playlists } @@ -30,7 +34,6 @@ type scanState struct { libraries model.Libraries // Store libraries list for consistency across phases targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans totalLibraryCount int // Total number of libraries (unfiltered), for cross-library move detection - failedLibs map[int]bool // Libraries that could not be scanned in this run } func (s *scanState) sendProgress(info *ProgressInfo) { @@ -47,13 +50,6 @@ func (s *scanState) sendWarning(msg string) { s.sendProgress(&ProgressInfo{Warning: msg}) } -func (s *scanState) markFailed(libID int) { - if s.failedLibs == nil { - s.failedLibs = map[int]bool{} - } - s.failedLibs[libID] = true -} - func (s *scanState) sendError(err error) { s.sendProgress(&ProgressInfo{Error: err.Error()}) } @@ -73,7 +69,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] } // Get libraries and optionally filter by targets - allLibs, err := s.ds.Library().GetAll(ctx) + allLibs, err := s.ds.Library(ctx).GetAll() if err != nil { state.sendWarning(fmt.Sprintf("getting libraries: %s", err)) return @@ -84,12 +80,8 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Selective scan: filter libraries and build targets map state.targets = make(map[int][]string) - libPaths := slice.ToMap(allLibs, func(lib model.Library) (int, string) { - return lib.ID, lib.Path - }) - for _, target := range targets { - folderPath := model.LibraryRelativePath(libPaths[target.LibraryID], target.FolderPath) + folderPath := target.FolderPath if folderPath == "" { folderPath = "." } @@ -116,23 +108,19 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] if state.isSelectiveScan() { scanType += "-selective" } - _ = s.ds.Property().Put(ctx, consts.LastScanTypeKey, scanType) - _ = s.ds.Property().Put(ctx, consts.LastScanStartTimeKey, startTime.Format(time.RFC3339)) + _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType) + _ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339)) // if there was a full scan in progress, force a full scan if !state.fullScan { for _, lib := range state.libraries { - // A pending PID rescan already restarts in full through its own job - if lib.NeedsPIDRescan() { - continue - } if lib.FullScanInProgress { log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name) state.fullScan = true if state.isSelectiveScan() { - _ = s.ds.Property().Put(ctx, consts.LastScanTypeKey, "full-selective") + _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full-selective") } else { - _ = s.ds.Property().Put(ctx, consts.LastScanTypeKey, "full") + _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full") } break } @@ -149,7 +137,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] err = run.Sequentially( // Phase 1: Scan all libraries and import new/updated files - runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds)), + runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw)), // Phase 2: Process missing files, checking for moves runPhase[*missingTracks](ctx, 2, createPhaseMissingTracks(ctx, &state, s.ds)), @@ -160,7 +148,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds)), // Phase 4: Import/update playlists - runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls)), + runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls, s.cw)), ), // Final Steps (cannot be parallelized): @@ -168,23 +156,23 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Run GC if there were any changes (Remove dangling tracks, empty albums and artists, and orphan annotations) s.runGC(ctx, &state), - // Queue artwork for entities that never resolved (after GC, so nothing dangling is queued) - s.runEnqueueMissingArtwork(ctx, &state), - // Refresh artist and tags stats s.runRefreshStats(ctx, &state), // 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) - _ = s.ds.Property().Put(ctx, consts.LastScanErrorKey, err.Error()) + _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error()) state.sendError(err) return } - _ = s.ds.Property().Put(ctx, consts.LastScanErrorKey, "") + _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, "") if state.changesDetected.Load() { state.sendProgress(&ProgressInfo{ChangesDetected: true}) @@ -204,14 +192,9 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt var successfulLibs []model.Library for _, lib := range state.libraries { - // A library with a changed PID config restarts its scan: resuming would skip the folders that - // the interrupted scan already processed with the old config - pidRescan := lib.NeedsPIDRescan() - if lib.LastScanStartedAt.IsZero() || pidRescan { + if lib.LastScanStartedAt.IsZero() { // This is a new scan - mark it as started - err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan || pidRescan) - }, "scanner: begin library scan") + err := s.ds.Library(ctx).ScanBegin(lib.ID, state.fullScan) if err != nil { log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err) state.sendWarning(err.Error()) @@ -219,7 +202,7 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt } // Reload library to get updated state (timestamps, etc.) - reloadedLib, err := s.ds.Library().Get(ctx, lib.ID) + reloadedLib, err := s.ds.Library(ctx).Get(lib.ID) if err != nil { log.Error(ctx, "Scanner: Error reloading library", "lib", lib.Name, err) state.sendWarning(err.Error()) @@ -247,7 +230,7 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error { return func() error { state.sendProgress(&ProgressInfo{ForceUpdate: true}) - return s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { + return s.ds.WithTx(func(tx model.DataStore) error { if state.changesDetected.Load() { start := time.Now() @@ -258,7 +241,9 @@ func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error log.Debug(ctx, "Scanner: Running selective GC", "libraryIDs", libraryIDs) } - if err := tx.GC(ctx, libraryIDs...); err != nil { + err := tx.GC(ctx, libraryIDs...) + if err != nil { + log.Error(ctx, "Scanner: Error running GC", err) return fmt.Errorf("running GC: %w", err) } log.Debug(ctx, "Scanner: GC completed", "elapsed", time.Since(start)) @@ -270,33 +255,6 @@ func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error } } -// runEnqueueMissingArtwork is the safety net for entities phase 1 never enqueued. -func (s *scannerImpl) runEnqueueMissingArtwork(ctx context.Context, state *scanState) func() error { - return func() error { - if !state.changesDetected.Load() { - log.Debug(ctx, "Scanner: No changes detected, skipping artwork enqueue") - return nil - } - start := time.Now() - var total int64 - for _, kind := range []model.Kind{model.KindAlbumArtwork, model.KindArtistArtwork} { - var n int64 - err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - var err error - n, err = tx.ArtworkQueue().EnqueueAllMissing(ctx, kind, model.ArtworkPriorityScan) - return err - }, "scanner: enqueue missing artwork") - if err != nil { - log.Error(ctx, "Scanner: Error enqueueing missing artwork", "kind", kind, err) - return fmt.Errorf("enqueueing missing artwork: %w", err) - } - total += n - } - log.Debug(ctx, "Scanner: Enqueued missing artwork", "items", total, "elapsed", time.Since(start)) - return nil - } -} - func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) func() error { return func() error { if !state.changesDetected.Load() { @@ -304,7 +262,7 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun return nil } start := time.Now() - stats, err := s.ds.Artist().RefreshStats(ctx, state.fullScan) + stats, err := s.ds.Artist(ctx).RefreshStats(state.fullScan) if err != nil { log.Error(ctx, "Scanner: Error refreshing artists stats", err) return fmt.Errorf("refreshing artists stats: %w", err) @@ -312,9 +270,7 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun log.Debug(ctx, "Scanner: Refreshed artist stats", "stats", stats, "elapsed", time.Since(start)) start = time.Now() - err = s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Tag().UpdateCounts(ctx) - }, "scanner: update tag counts") + err = s.ds.Tag(ctx).UpdateCounts() if err != nil { log.Error(ctx, "Scanner: Error updating tag counts", err) return fmt.Errorf("updating tag counts: %w", err) @@ -324,25 +280,40 @@ 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() - return s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { + return s.ds.WithTx(func(tx model.DataStore) error { for _, lib := range state.libraries { - if err := tx.Library().ScanEnd(ctx, lib.ID); err != nil { - return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err) + err := tx.Library(ctx).ScanEnd(lib.ID) + if err != nil { + log.Error(ctx, "Scanner: Error updating last scan completed", "lib", lib.Name, err) + return fmt.Errorf("updating last scan completed: %w", err) } - // A selective scan covers only part of the library, so the rest may still use the old PID - // config. A library that could not be scanned did not apply it either. - if !state.isSelectiveScan() && !state.failedLibs[lib.ID] { - if err := tx.Library().SetScannedPID(ctx, lib.ID, lib.EffectivePID()); err != nil { - return fmt.Errorf("updating PID conf for %s: %w", lib.Name, err) - } + err = tx.Property(ctx).Put(consts.PIDTrackKey, conf.Server.PID.Track) + if err != nil { + log.Error(ctx, "Scanner: Error updating track PID conf", err) + return fmt.Errorf("updating track PID conf: %w", err) + } + err = tx.Property(ctx).Put(consts.PIDAlbumKey, conf.Server.PID.Album) + if err != nil { + log.Error(ctx, "Scanner: Error updating album PID conf", err) + return fmt.Errorf("updating album PID conf: %w", err) } if state.changesDetected.Load() { log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name) - if err := tx.Library().RefreshStats(ctx, lib.ID); err != nil { - return fmt.Errorf("refreshing library stats for %s: %w", lib.Name, err) + if err := tx.Library(ctx).RefreshStats(lib.ID); err != nil { + log.Error(ctx, "Scanner: Error refreshing library stats", "lib", lib.Name, err) + return fmt.Errorf("refreshing library stats: %w", err) } } else { log.Debug(ctx, "Scanner: No changes detected, skipping library stats refresh", "lib", lib.Name) diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index e6797df36..8f0dcd340 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -2,8 +2,8 @@ package scanner_test import ( "context" - "database/sql" "fmt" + "os" "path/filepath" "runtime" "testing" @@ -12,6 +12,7 @@ import ( "github.com/dustin/go-humanize" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -21,8 +22,6 @@ import ( "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/singleton" "go.uber.org/goleak" ) @@ -33,30 +32,16 @@ func BenchmarkScan(b *testing.B) { goleak.IgnoreAnyFunction("testing.(*B).doBench"), // Ignore database/sql.(*DB).connectionOpener, as we are not closing the database connection goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener"), - // A preceding TestScanner leaves Ginkgo's interrupt handler running. - goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - // The notify library keeps watcher goroutines alive after Stop(); recursive on macOS, nonrecursive on Linux. - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), ) - tests.Init(b, false) - - tmpDir := b.TempDir() + tmpDir := os.TempDir() conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") - // The default library is seeded from MusicFolder, and its path cannot be changed afterwards - conf.Server.MusicFolder = "fake:///music" - // TestScanner may run first and close the shared DB singleton; drop it so db.Init - // opens a fresh one whether or not the test suite ran before this benchmark. - singleton.DeleteInstance[*sql.DB]() - // Close before b.TempDir cleanup runs, or Windows cannot delete the open DB/WAL files. - defer db.Init(context.Background())() + db.Init(context.Background()) ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false - s := scanner.New(context.Background(), ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) fs := storagetest.FakeFS{} storagetest.Register("fake", &fs) @@ -82,7 +67,7 @@ func BenchmarkScan(b *testing.B) { }) lib := model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} - err := ds.Library().Put(b.Context(), &lib) + err := ds.Library(context.Background()).Put(&lib) if err != nil { b.Fatal(err) } diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index f6634c875..3ae50933c 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -11,6 +11,7 @@ import ( "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" "github.com/navidrome/navidrome/core/playlists" @@ -75,16 +76,16 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { IsAdmin: true, NewPassword: "password", } - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) // Create two test libraries (let DB auto-assign IDs) lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} lib2 = model.Library{Name: "Jazz Collection", Path: "jazz:///music"} - Expect(ds.Library().Put(ctx, &lib1)).To(Succeed()) - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + Expect(ds.Library(ctx).Put(&lib1)).To(Succeed()) + Expect(ds.Library(ctx).Put(&lib2)).To(Succeed()) }) runScanner := func(ctx context.Context, fullScan bool) error { @@ -122,7 +123,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Check Rock library media files - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, Sort: "title", }) @@ -138,7 +139,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { } // Check Jazz library media files - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, Sort: "title", }) @@ -158,7 +159,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Check Rock library albums - rockAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, Sort: "name", }) @@ -172,7 +173,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(rockAlbums[1].SongCount).To(Equal(2)) // Check Jazz library albums - jazzAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, Sort: "name", }) @@ -190,7 +191,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Check Rock library folders - rockFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ + rockFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -201,7 +202,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { } // Check Jazz library folders - jazzFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ + jazzFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -218,7 +219,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { // Check library-artist associations // Get all artists and check library associations - allArtists, err := ds.Artist().GetAll(ctx) + allArtists, err := ds.Artist(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) rockArtistNames := []string{} @@ -262,7 +263,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Check Rock library stats - rockLib, err := ds.Library().Get(ctx, lib1.ID) + rockLib, err := ds.Library(ctx).Get(lib1.ID) Expect(err).ToNot(HaveOccurred()) Expect(rockLib.TotalSongs).To(Equal(4)) Expect(rockLib.TotalAlbums).To(Equal(2)) @@ -271,7 +272,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(rockLib.TotalFolders).To(Equal(2)) // Abbey Road, IV (only folders with audio files) // Check Jazz library stats - jazzLib, err := ds.Library().Get(ctx, lib2.ID) + jazzLib, err := ds.Library(ctx).Get(lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(4)) Expect(jazzLib.TotalAlbums).To(Equal(2)) @@ -285,25 +286,25 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify rock library stats - rockLib, err := ds.Library().Get(ctx, lib1.ID) + rockLib, err := ds.Library(ctx).Get(lib1.ID) Expect(err).ToNot(HaveOccurred()) Expect(rockLib.TotalSongs).To(Equal(4)) Expect(rockLib.TotalAlbums).To(Equal(2)) // Verify jazz library stats - jazzLib, err := ds.Library().Get(ctx, lib2.ID) + jazzLib, err := ds.Library(ctx).Get(lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(4)) Expect(jazzLib.TotalAlbums).To(Equal(2)) // Verify that libraries don't interfere with each other - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(rockFiles).To(HaveLen(4)) - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -316,7 +317,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify that rock library only contains rock content - rockAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -325,7 +326,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(rockAlbumNames).ToNot(ContainElements("Kind of Blue", "Giant Steps")) // Verify that jazz library only contains jazz content - jazzAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -365,7 +366,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { var rockCount, jazzCount int64 // Get Jeff Beck artist ID - jeffArtists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ + jeffArtists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"name": "Jeff Beck"}, }) Expect(err).ToNot(HaveOccurred()) @@ -389,14 +390,14 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(jazzCount).To(Equal(int64(1))) // Verify Jeff Beck albums are in correct libraries - rockAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID, "album_artist": "Jeff Beck"}, }) Expect(err).ToNot(HaveOccurred()) Expect(rockAlbums).To(HaveLen(1)) Expect(rockAlbums[0].Name).To(Equal("Truth")) - jazzAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID, "album_artist": "Jeff Beck"}, }) Expect(err).ToNot(HaveOccurred()) @@ -426,13 +427,13 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify initial state - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(rockFiles).To(HaveLen(1)) - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -442,13 +443,13 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) // Verify counts remain the same - rockFiles, err = ds.MediaFile().GetAll(ctx, model.QueryOptions{ + rockFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(rockFiles).To(HaveLen(1)) - jazzFiles, err = ds.MediaFile().GetAll(ctx, model.QueryOptions{ + jazzFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -485,7 +486,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) // Check that only the rock library file is marked as missing - missingRockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + missingRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"library_id": lib1.ID}, squirrel.Eq{"missing": true}, @@ -496,7 +497,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(missingRockFiles[0].Title).To(Equal("Shoot to Thrill")) // Check that jazz library files are not affected - missingJazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + missingJazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"library_id": lib2.ID}, squirrel.Eq{"missing": true}, @@ -506,7 +507,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(missingJazzFiles).To(HaveLen(0)) // Verify non-missing files - presentRockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + presentRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"library_id": lib1.ID}, squirrel.Eq{"missing": false}, @@ -548,7 +549,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem errors") // Jazz library should have been scanned successfully - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -557,7 +558,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(jazzFiles[1].Title).To(BeElementOf("So What", "Freddie Freeloader")) // Rock library may have partial content (depending on scanner implementation) - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -565,12 +566,12 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { _ = rockFiles // Verify jazz library stats are correct - jazzLib, err := ds.Library().Get(ctx, lib2.ID) + jazzLib, err := ds.Library(ctx).Get(lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(2)) // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) @@ -586,20 +587,20 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(warnings).ToNot(BeEmpty(), "Should have warnings for multiple filesystem errors") // Jazz library should be completely unaffected - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(jazzFiles).To(HaveLen(2)) // Jazz library statistics should be accurate - jazzLib, err := ds.Library().Get(ctx, lib2.ID) + jazzLib, err := ds.Library(ctx).Get(lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(2)) Expect(jazzLib.TotalAlbums).To(Equal(1)) // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) @@ -623,7 +624,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { It("should propagate database errors and stop scanning", func() { // Install mock repo that injects DB error mfRepo := &mockMediaFileRepo{ - MediaFileRepository: ds.RealDS.MediaFile(), + MediaFileRepository: ds.RealDS.MediaFile(ctx), GetMissingAndMatchingError: errors.New("database connection failed"), } ds.MockedMediaFile = mfRepo @@ -632,7 +633,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, false)).To(MatchError(ContainSubstring("database connection failed"))) // Error should be recorded in scanner properties - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "") + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(ContainSubstring("database connection failed")) }) @@ -640,7 +641,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { It("should preserve error information in scanner properties", func() { // Install mock repo that injects DB error mfRepo := &mockMediaFileRepo{ - MediaFileRepository: ds.RealDS.MediaFile(), + MediaFileRepository: ds.RealDS.MediaFile(ctx), GetMissingAndMatchingError: errors.New("critical database error"), } ds.MockedMediaFile = mfRepo @@ -649,12 +650,12 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, false)).To(HaveOccurred()) // Check that error is recorded in scanner properties - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "") + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(ContainSubstring("critical database error")) // Scan type should still be recorded - scanType, _ := ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") + scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") Expect(scanType).To(BeElementOf("incremental", "quick")) }) }) @@ -687,7 +688,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem error") // Jazz library should scan completely successfully - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -695,13 +696,13 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(jazzFiles[0].Title).To(Equal("Chameleon")) // Jazz library statistics should be accurate - jazzLib, err := ds.Library().Get(ctx, lib2.ID) + jazzLib, err := ds.Library(ctx).Get(lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(1)) Expect(jazzLib.TotalAlbums).To(Equal(1)) // Rock library may have partial content (depending on scanner implementation) - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) @@ -709,7 +710,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { _ = rockFiles // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) @@ -724,22 +725,22 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(warnings).ToNot(BeEmpty(), "Should have warnings for file corruption") // Verify that the working parts completed successfully - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(jazzFiles).To(HaveLen(1)) // Scanner properties should reflect successful completion despite warnings - scanType, _ := ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") + scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") Expect(scanType).To(Equal("full")) // Start time should be recorded - startTimeStr, _ := ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") + startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") Expect(startTimeStr).ToNot(BeEmpty()) // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) @@ -780,30 +781,30 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(warnings).To(BeEmpty(), "Should have no warnings after error recovery") // Verify both libraries now have content (at least jazz should work) - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib1.ID}, }) Expect(err).ToNot(HaveOccurred()) // The scanner should recover and import both rock files Expect(len(rockFiles)).To(Equal(2)) - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) Expect(jazzFiles).To(HaveLen(1)) // Both libraries should have correct content counts - rockLib, err := ds.Library().Get(ctx, lib1.ID) + rockLib, err := ds.Library(ctx).Get(lib1.ID) Expect(err).ToNot(HaveOccurred()) Expect(rockLib.TotalSongs).To(Equal(2)) - jazzLib, err := ds.Library().Get(ctx, lib2.ID) + jazzLib, err := ds.Library(ctx).Get(lib2.ID) Expect(err).ToNot(HaveOccurred()) Expect(jazzLib.TotalSongs).To(Equal(1)) // Error should be empty (successful recovery) - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) @@ -822,183 +823,17 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Validate properties - scanType, _ := ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") + scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") Expect(scanType).To(Equal("full")) - startTimeStr, _ := ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") + startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") Expect(startTimeStr).ToNot(BeEmpty()) _, err := time.Parse(time.RFC3339, startTimeStr) Expect(err).ToNot(HaveOccurred()) - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") Expect(err).ToNot(HaveOccurred()) Expect(lastError).To(BeEmpty()) }) }) - - Context("Per-library PID config", func() { - albumsOf := func(libID int) model.Albums { - // The mock datastore's GC is a no-op, so run the real one to purge the albums left - // empty by a regroup, as the scanner does in production - Expect(ds.RealDS.GC(ctx)).To(Succeed()) - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": libID, "missing": false}, - Sort: "name", - }) - Expect(err).ToNot(HaveOccurred()) - return albums - } - trackByTitle := func(libID int, title string) model.MediaFile { - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": libID, "title": title}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(1)) - return mfs[0] - } - rockTitles := func() []string { - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib1.ID}}) - Expect(err).ToNot(HaveOccurred()) - return slice.Map(mfs, func(mf model.MediaFile) string { return mf.Title }) - } - // changeRockInDB edits the rock track in the DB only. A full rescan of the rock library would - // restore the title from the file tags, a quick scan leaves it alone. - changeRockInDB := func() { - _, err := db.Db().ExecContext(ctx, "update media_file set title = 'Changed In DB' where library_id = ?", lib1.ID) - Expect(err).ToNot(HaveOccurred()) - } - // changeBlueTrainInDB does the same for one jazz track - changeBlueTrainInDB := func() { - _, err := db.Db().ExecContext(ctx, "update media_file set title = 'Blue Train In DB' where library_id = ? and title = 'Blue Train'", lib2.ID) - Expect(err).ToNot(HaveOccurred()) - } - - BeforeEach(func() { - beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969}) - _ = createFS("rock", fstest.MapFS{ - "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")), - }) - - miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959}) - coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960}) - blueTrain := template(_t{"albumartist": "John Coltrane", "album": "Blue Train", "year": 1957}) - _ = createFS("jazz", fstest.MapFS{ - "Loose/01 - So What.mp3": miles(track(1, "So What")), - "Loose/02 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")), - "Coltrane/Blue Train/01 - Blue Train.mp3": blueTrain(track(1, "Blue Train")), - }) - }) - - It("regroups only the library whose PID config changed, keeping annotations", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - Expect(albumsOf(lib2.ID)).To(HaveLen(3)) - - // Star Blue Train, to check the star follows the album to its new ID - oldBlueTrain := trackByTitle(lib2.ID, "Blue Train") - Expect(ds.Album().SetStar(ctx, true, oldBlueTrain.AlbumID)).To(Succeed()) - changeRockInDB() - - lib2.PIDAlbum = "folder" - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - Expect(runScanner(ctx, false)).To(Succeed()) - - // Jazz is grouped by folder now: "Loose" is one album - Expect(albumsOf(lib2.ID)).To(HaveLen(2)) - Expect(trackByTitle(lib2.ID, "So What").AlbumID).To(Equal(trackByTitle(lib2.ID, "Giant Steps").AlbumID)) - - newBlueTrain := trackByTitle(lib2.ID, "Blue Train") - Expect(newBlueTrain.AlbumID).ToNot(Equal(oldBlueTrain.AlbumID)) - album, err := ds.Album().Get(ctx, newBlueTrain.AlbumID) - Expect(err).ToNot(HaveOccurred()) - Expect(album.Starred).To(BeTrue()) - - // Rock only got a quick scan - Expect(rockTitles()).To(ConsistOf("Changed In DB")) - - jazz, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazz.ScannedPIDAlbum).To(Equal("folder")) - Expect(jazz.PIDChanged()).To(BeFalse()) - rock, err := ds.Library().Get(ctx, lib1.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(rock.PIDChanged()).To(BeFalse()) - }) - - It("rescans only libraries that follow the global config", func() { - lib2.PIDAlbum = "folder" - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - Expect(runScanner(ctx, true)).To(Succeed()) - changeRockInDB() - changeBlueTrainInDB() - - conf.Server.PID.Album = "album" - Expect(runScanner(ctx, false)).To(Succeed()) - - // Rock follows the global config, so it was rescanned in full and its title restored - Expect(rockTitles()).To(ConsistOf("Come Together")) - // Jazz has its own override, so it only got a quick scan - trackByTitle(lib2.ID, "Blue Train In DB") - jazz, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazz.ScannedPIDAlbum).To(Equal("folder")) - }) - - It("restarts an interrupted scan when the PID config changed meanwhile", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - // Simulate a quick scan of jazz that was interrupted after it had processed every folder: - // the folders were updated after the (old) scan start time - _, err := db.Db().ExecContext(ctx, "update library set last_scan_started_at = ?, full_scan_in_progress = false where id = ?", - time.Now().Add(-time.Hour), lib2.ID) - Expect(err).ToNot(HaveOccurred()) - - lib2.PIDAlbum = "folder" - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - Expect(runScanner(ctx, false)).To(Succeed()) - - // Every folder was revisited with the new config - Expect(albumsOf(lib2.ID)).To(HaveLen(2)) - }) - - It("does not turn an interrupted PID rescan into a full scan of every library", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - changeRockInDB() - lib2.PIDAlbum = "folder" - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - - // Simulate a PID full scan of jazz that was interrupted - Expect(ds.Library().ScanBegin(ctx, lib2.ID, true)).To(Succeed()) - Expect(runScanner(ctx, false)).To(Succeed()) - - // Rock only got a quick scan, jazz was rescanned with the new config - Expect(rockTitles()).To(ConsistOf("Changed In DB")) - Expect(albumsOf(lib2.ID)).To(HaveLen(2)) - }) - - It("does not record the PID config for a library that could not be scanned", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - broken := model.Library{Name: "Broken", Path: "unregistered:///music", PIDAlbum: "folder"} - Expect(ds.Library().Put(ctx, &broken)).To(Succeed()) - - // The scan reports an error for the broken library, and still finishes the others - _ = runScanner(ctx, false) - - reloaded, err := ds.Library().Get(ctx, broken.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(reloaded.PIDChanged()).To(BeTrue()) - }) - - It("does not record the PID config after a selective scan", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - lib2.PIDAlbum = "folder" - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - - _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: lib2.ID, FolderPath: "Loose"}}) - Expect(err).ToNot(HaveOccurred()) - - jazz, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazz.PIDChanged()).To(BeTrue()) - }) - }) }) diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 2f27b74ce..6c70eb268 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -4,12 +4,11 @@ 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" "github.com/navidrome/navidrome/core/playlists" @@ -63,13 +62,13 @@ var _ = Describe("ScanFolders", Ordered, func() { IsAdmin: true, NewPassword: "password", } - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} - Expect(ds.Library().Put(ctx, &lib)).To(Succeed()) + Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) // Initialize fake filesystem fsys = storagetest.FakeFS{} @@ -81,7 +80,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"}) - fsys = createFS(fstest.MapFS{ + 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")), @@ -101,7 +100,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(warnings).To(BeEmpty()) // Verify all tracks in rock and jazz folders (including subdirectories) were imported - allFiles, err := ds.MediaFile().GetAll(ctx) + allFiles, err := ds.MediaFile(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) // Should have 5 tracks (all rock and jazz tracks including subdirectories) @@ -123,38 +122,6 @@ var _ = Describe("ScanFolders", Ordered, func() { // Verify files in the pop folder were NOT scanned Expect(paths).ToNot(ContainElement("pop/track6.mp3")) - Expect(ds.Property().Get(ctx, 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().Get(ctx, 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().Get(ctx, 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().Put(ctx, &otherLib)).To(Succeed()) - Expect(ds.Library().ScanBegin(ctx, lib.ID, true)).To(Succeed()) - - lastAnalyze := "2026-07-09T12:00:00Z" - Expect(ds.Property().Put(ctx, consts.LastDBAnalyzeAtKey, lastAnalyze)).To(Succeed()) - Expect(ds.Property().Put(ctx, consts.DBAnalyzePendingKey, "0")).To(Succeed()) - - _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: otherLib.ID, FolderPath: "."}}) - Expect(err).ToNot(HaveOccurred()) - Expect(ds.Property().Get(ctx, consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) }) }) @@ -187,7 +154,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Verify initial state - all folders exist - folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) Expect(err).ToNot(HaveOccurred()) Expect(folders).To(HaveLen(4)) // root, Artist, Album1, Album2 @@ -204,7 +171,7 @@ var _ = Describe("ScanFolders", Ordered, func() { } // Verify all tracks exist - allTracks, err := ds.MediaFile().GetAll(ctx) + allTracks, err := ds.MediaFile(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) Expect(allTracks).To(HaveLen(4)) @@ -239,29 +206,29 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Verify the deleted child folder is now marked as missing - deletedFolder, err := ds.Folder().Get(ctx, album2FolderID) + deletedFolder, err := ds.Folder(ctx).Get(album2FolderID) Expect(err).ToNot(HaveOccurred()) Expect(deletedFolder.Missing).To(BeTrue(), "Deleted child folder should be marked as missing") // Verify the deleted folder's tracks are marked as missing for _, trackID := range album2TrackIDs { - track, err := ds.MediaFile().Get(ctx, trackID) + track, err := ds.MediaFile(ctx).Get(trackID) Expect(err).ToNot(HaveOccurred()) Expect(track.Missing).To(BeTrue(), "Track in deleted folder should be marked as missing") } // Verify the parent folder is still present and not marked as missing - parentFolder, err := ds.Folder().Get(ctx, artistFolderID) + parentFolder, err := ds.Folder(ctx).Get(artistFolderID) Expect(err).ToNot(HaveOccurred()) Expect(parentFolder.Missing).To(BeFalse(), "Parent folder should not be marked as missing") // Verify the sibling folder and its tracks are still present and not missing - siblingFolder, err := ds.Folder().Get(ctx, album1FolderID) + siblingFolder, err := ds.Folder(ctx).Get(album1FolderID) Expect(err).ToNot(HaveOccurred()) Expect(siblingFolder.Missing).To(BeFalse(), "Sibling folder should not be marked as missing") for _, trackID := range album1TrackIDs { - track, err := ds.MediaFile().Get(ctx, trackID) + track, err := ds.MediaFile(ctx).Get(trackID) Expect(err).ToNot(HaveOccurred()) Expect(track.Missing).To(BeFalse(), "Track in sibling folder should not be marked as missing") } @@ -283,7 +250,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Verify nested folders were created - allFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) + allFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) Expect(err).ToNot(HaveOccurred()) Expect(len(allFolders)).To(BeNumerically(">", 4), "Should have more folders with nested structure") @@ -301,7 +268,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Verify all Help! folders (including nested ones) are marked as missing - missingFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ + missingFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"library_id": lib.ID}, squirrel.Eq{"missing": true}, @@ -311,7 +278,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(len(missingFolders)).To(BeNumerically(">", 0), "At least one folder should be marked as missing") // Verify all tracks in deleted folders are marked as missing - allTracks, err := ds.MediaFile().GetAll(ctx) + allTracks, err := ds.MediaFile(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) Expect(allTracks).To(HaveLen(6)) diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go index 07ffd6e3a..9ee6fc89b 100644 --- a/scanner/scanner_suite_test.go +++ b/scanner/scanner_suite_test.go @@ -2,43 +2,28 @@ 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) { - // Detect any goroutine leaks in the scanner code under test - defer goleak.VerifyNone(t, - goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - // The notify library keeps internal goroutines alive after Stop(). The backend picks the tree per - // platform: recursive on macOS (FSEvents), nonrecursive on Linux (inotify), so ignore both. - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), - ) + // Only run goleak checks when the GOLEAK env var is set + if os.Getenv("GOLEAK") != "" { + // Detect any goroutine leaks in the scanner code under test + defer goleak.VerifyNone(t, + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // The notify library creates internal goroutines for file watching that persist after Stop() is called. + // These are created by the plugins package tests and are expected behavior. + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), + ) + } tests.Init(t, true) defer db.Close(context.Background()) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 4ce8cce1e..7bf91d64f 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -2,21 +2,16 @@ package scanner_test import ( "context" - "database/sql" "errors" - "fmt" - "os" "path/filepath" - "sync/atomic" "testing/fstest" - "time" "github.com/Masterminds/squirrel" "github.com/google/uuid" - "github.com/mattn/go-sqlite3" "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" "github.com/navidrome/navidrome/core/playlists" @@ -56,10 +51,7 @@ var _ = Describe("Scanner", Ordered, func() { BeforeAll(func() { ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) - // The DB stays open until the suite ends, and Windows can't delete an open file - tmpDir, err := os.MkdirTemp("", "scanner-test") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { _ = os.RemoveAll(tmpDir) }) + tmpDir := GinkgoT().TempDir() conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") log.Warn("Using DB at " + conf.Server.DbPath) //conf.Server.DbPath = ":memory:" @@ -78,7 +70,7 @@ var _ = Describe("Scanner", Ordered, func() { ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} mfRepo = &mockMediaFileRepo{ - MediaFileRepository: ds.RealDS.MediaFile(), + MediaFileRepository: ds.RealDS.MediaFile(ctx), } ds.MockedMediaFile = mfRepo @@ -90,13 +82,13 @@ var _ = Describe("Scanner", Ordered, func() { IsAdmin: true, NewPassword: "password", } - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} - Expect(ds.Library().Put(ctx, &lib)).To(Succeed()) + Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) }) runScanner := func(ctx context.Context, fullScan bool) error { @@ -104,22 +96,6 @@ var _ = Describe("Scanner", Ordered, func() { return err } - // Stands in for the artwork worker: drains the queue and records every item as resolved, - // so a later scan can only queue genuine reprocessing. - resolveQueuedArtwork := func() []model.ArtworkQueueItem { - GinkgoHelper() - queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - for _, it := range queued { - Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: it.ItemKind, ItemID: it.ItemID, ImageType: it.ImageType, - Hash: "resolved", Source: "embedded", UpdatedAt: time.Now(), - })).To(Succeed()) - Expect(ds.ArtworkQueue().DeleteIfUnchanged(ctx, it.ItemKind, it.ItemID, it.ImageType, it.RetryAt)).To(Succeed()) - } - return queued - } - Context("Simple library, 'artis/album/track - title.mp3'", func() { var help, revolver func(...map[string]any) *fstest.MapFile var fsys storagetest.FakeFS @@ -140,7 +116,7 @@ var _ = Describe("Scanner", Ordered, func() { It("should import all folders", func() { Expect(runScanner(ctx, true)).To(Succeed()) - folders, _ := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) + folders, _ := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) paths := slice.Map(folders, func(f model.Folder) string { return f.Name }) Expect(paths).To(SatisfyAll( HaveLen(4), @@ -150,7 +126,7 @@ var _ = Describe("Scanner", Ordered, func() { It("should import all mediafiles", func() { Expect(runScanner(ctx, true)).To(Succeed()) - mfs, _ := ds.MediaFile().GetAll(ctx) + mfs, _ := ds.MediaFile(ctx).GetAll() paths := slice.Map(mfs, func(f model.MediaFile) string { return f.Title }) Expect(paths).To(SatisfyAll( HaveLen(7), @@ -163,7 +139,7 @@ var _ = Describe("Scanner", Ordered, func() { It("should import all albums", func() { Expect(runScanner(ctx, true)).To(Succeed()) - albums, _ := ds.Album().GetAll(ctx, model.QueryOptions{Sort: "name"}) + albums, _ := ds.Album(ctx).GetAll(model.QueryOptions{Sort: "name"}) Expect(albums).To(HaveLen(2)) Expect(albums[0]).To(SatisfyAll( HaveField("Name", Equal("Help!")), @@ -174,81 +150,28 @@ var _ = Describe("Scanner", Ordered, func() { HaveField("SongCount", Equal(4)), )) }) - It("should enqueue artwork resolution for the scanned albums and artists", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - albums, _ := ds.Album().GetAll(ctx) - artists, _ := ds.Artist().GetAll(ctx, model.QueryOptions{Filters: squirrel.NotEq{"name": consts.UnknownArtist}}) - queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - - for _, al := range albums { - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "al"), - HaveField("ItemID", al.ID), - HaveField("Priority", model.ArtworkPriorityScan), - ))) - } - for _, ar := range artists { - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "ar"), - HaveField("ItemID", ar.ID), - HaveField("Priority", model.ArtworkPriorityScan), - ))) - } - }) - It("should not re-enqueue already resolved artwork on a repeat full scan", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - Expect(resolveQueuedArtwork()).ToNot(BeEmpty()) - - Expect(runScanner(ctx, true)).To(Succeed()) - - requeued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(requeued).To(BeEmpty()) - }) }) When("a file was changed", func() { It("should update the media_file", func() { Expect(runScanner(ctx, true)).To(Succeed()) - mf, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) + mf, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) Expect(err).ToNot(HaveOccurred()) Expect(mf[0].Tags).ToNot(HaveKey("barcode")) fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"barcode": "123"}) Expect(runScanner(ctx, true)).To(Succeed()) - mf, err = ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) + mf, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) Expect(err).ToNot(HaveOccurred()) Expect(mf[0].Tags).To(HaveKeyWithValue(model.TagName("barcode"), []string{"123"})) }) - It("should re-enqueue the album artwork even though it already resolved", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") - Expect(runScanner(ctx, true)).To(Succeed()) - - resolveQueuedArtwork() - - fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"producer": "George Martin"}) - Expect(runScanner(ctx, false)).To(Succeed()) - - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) - Expect(err).ToNot(HaveOccurred()) - requeued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(requeued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "al"), - HaveField("ItemID", albums[0].ID), - ))) - }) - It("should update the album", func() { tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") Expect(runScanner(ctx, true)).To(Succeed()) - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) Expect(err).ToNot(HaveOccurred()) Expect(albums).ToNot(BeEmpty()) Expect(albums[0].Participants.First(model.RoleProducer).Name).To(BeEmpty()) @@ -257,198 +180,11 @@ var _ = Describe("Scanner", Ordered, func() { fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"producer": "George Martin"}) Expect(runScanner(ctx, false)).To(Succeed()) - albums, err = ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) + albums, err = ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) Expect(err).ToNot(HaveOccurred()) Expect(albums[0].Participants.First(model.RoleProducer).Name).To(Equal("George Martin")) Expect(albums[0].SongCount).To(Equal(3)) }) - - It("invalidates the media_file artwork state so new embedded art is picked up lazily", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - mf, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) - Expect(err).ToNot(HaveOccurred()) - Expect(mf).ToNot(BeEmpty()) - trackID := mf[0].ID - - Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "mf", ItemID: trackID, ImageType: model.ImageTypePrimary, - Source: "embedded", Hash: "stalehash", - })).To(Succeed()) - - fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "reimport"}) - Expect(runScanner(ctx, true)).To(Succeed()) - - _, err = ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, trackID, model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - }) - - Context("Library with image files", func() { - var fsys storagetest.FakeFS - image := func(data string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(data)} } - - albumID := func(name string) string { - GinkgoHelper() - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).To(HaveLen(1)) - return albums[0].ID - } - artistID := func(name string) string { - GinkgoHelper() - artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"artist.name": name}}) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(1)) - return artists[0].ID - } - queuedItems := func() []model.ArtworkQueueItem { - GinkgoHelper() - queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - return queued - } - queueItemFor := func(kind, id string) OmegaMatcher { - return ContainElement(SatisfyAll( - HaveField("ItemKind", kind), - HaveField("ItemID", id), - HaveField("Priority", model.ArtworkPriorityScan), - )) - } - - BeforeEach(func() { - revolver := template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966}) - wall := template(_t{"albumartist": "Pink Floyd", "album": "The Wall", "year": 1979}) - fsys = createFS(fstest.MapFS{ - "The Beatles/artist.jpg": image("beatles-artist-v1"), - "The Beatles/Revolver/cover.jpg": image("revolver-cover-v1"), - "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), - "Pink Floyd/The Wall/cover.jpg": image("wall-cover-v1"), - "Pink Floyd/The Wall/CD1/01 - In the Flesh.mp3": wall(track(1, "In the Flesh?")), - "Pink Floyd/The Wall/CD2/01 - Hey You.mp3": wall(track(1, "Hey You")), - }) - Expect(runScanner(ctx, true)).To(Succeed()) - resolveQueuedArtwork() - }) - - It("re-enqueues only the album whose cover was replaced in place", func() { - fsys.Add("The Beatles/Revolver/cover.jpg", image("revolver-cover-v2")) - - Expect(runScanner(ctx, false)).To(Succeed()) - - queued := queuedItems() - Expect(queued).To(queueItemFor("al", albumID("Revolver"))) - Expect(queued).ToNot(ContainElement(HaveField("ItemID", albumID("The Wall")))) - Expect(queued).ToNot(ContainElement(HaveField("ItemKind", "ar"))) - }) - - It("re-enqueues the album when the cover above its disc folders changes", func() { - fsys.Add("Pink Floyd/The Wall/cover.jpg", image("wall-cover-v2")) - - Expect(runScanner(ctx, false)).To(Succeed()) - - Expect(queuedItems()).To(queueItemFor("al", albumID("The Wall"))) - }) - - It("re-enqueues the album when its cover is removed", func() { - fsys.Remove("The Beatles/Revolver/cover.jpg") - - Expect(runScanner(ctx, false)).To(Succeed()) - - Expect(queuedItems()).To(queueItemFor("al", albumID("Revolver"))) - }) - - It("enqueues the artist when an artist image is added to their folder", func() { - fsys.Add("Pink Floyd/artist.jpg", image("floyd-artist-v1")) - - Expect(runScanner(ctx, false)).To(Succeed()) - - queued := queuedItems() - Expect(queued).To(queueItemFor("ar", artistID("Pink Floyd"))) - Expect(queued).ToNot(ContainElement(HaveField("ItemID", artistID("The Beatles")))) - }) - - It("re-enqueues the artist when their artist image is replaced in place", func() { - fsys.Add("The Beatles/artist.jpg", image("beatles-artist-v2")) - - Expect(runScanner(ctx, false)).To(Succeed()) - - Expect(queuedItems()).To(queueItemFor("ar", artistID("The Beatles"))) - }) - - It("enqueues every artist under the folder when a shared artist image is added", func() { - fsys.Add("artist.png", image("shared-artist-v1")) - - Expect(runScanner(ctx, false)).To(Succeed()) - - queued := queuedItems() - Expect(queued).To(queueItemFor("ar", artistID("The Beatles"))) - Expect(queued).To(queueItemFor("ar", artistID("Pink Floyd"))) - }) - - It("enqueues the artist when an image lands in a folder first seen by a quick scan", func() { - // A quick scan must persist an artist folder that holds only subfolders, or the - // artist.jpg added later has no previous state to diff against. - kraftwerk := template(_t{"albumartist": "Kraftwerk", "album": "Autobahn", "year": 1974}) - files := fsys.MapFS - files["Kraftwerk/Autobahn/01 - Autobahn.mp3"] = kraftwerk(track(1, "Autobahn")) - fsys.SetFiles(files) - // Backdate the previous scan so this one's new artists are unambiguously newer: - // RefreshStats picks touched artists with a strict artist.updated_at > - // library.last_scan_at, and Windows' coarse clock can put both in one tick. - _, err := db.Db().ExecContext(ctx, "UPDATE library SET last_scan_at = ?", time.Now().Add(-time.Hour)) - Expect(err).ToNot(HaveOccurred()) - Expect(runScanner(ctx, false)).To(Succeed()) - resolveQueuedArtwork() - - fsys.Add("Kraftwerk/artist.jpg", image("kraftwerk-artist-v1")) - Expect(runScanner(ctx, false)).To(Succeed()) - - Expect(queuedItems()).To(queueItemFor("ar", artistID("Kraftwerk"))) - }) - - It("does not enqueue anything on a repeat full scan with no image changes", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - Expect(queuedItems()).To(BeEmpty()) - }) - }) - - 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")) }) }) @@ -464,7 +200,7 @@ var _ = Describe("Scanner", Ordered, func() { It("should not import the ignored file", func() { Expect(runScanner(ctx, true)).To(Succeed()) - mfs, err := ds.MediaFile().GetAll(ctx) + mfs, err := ds.MediaFile(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(1)) for _, mf := range mfs { @@ -486,11 +222,11 @@ var _ = Describe("Scanner", Ordered, func() { It("should import as one album", func() { Expect(runScanner(ctx, true)).To(Succeed()) - albums, err := ds.Album().GetAll(ctx) + albums, err := ds.Album(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) Expect(albums).To(HaveLen(1)) - mfs, err := ds.MediaFile().GetAll(ctx) + mfs, err := ds.MediaFile(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) for _, mf := range mfs { @@ -512,7 +248,7 @@ var _ = Describe("Scanner", Ordered, func() { It("should import as two distinct albums", func() { Expect(runScanner(ctx, true)).To(Succeed()) - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Sort: "release_date"}) + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Sort: "release_date"}) Expect(err).ToNot(HaveOccurred()) Expect(albums).To(HaveLen(2)) Expect(albums[0]).To(SatisfyAll( @@ -551,7 +287,7 @@ var _ = Describe("Scanner", Ordered, func() { By("Doing a full scan") Expect(runScanner(ctx, true)).To(Succeed()) - Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(4))) + Expect(ds.MediaFile(ctx).CountAll()).To(Equal(int64(4))) findByPath = createFindByPath(ctx, ds) }) @@ -559,7 +295,7 @@ var _ = Describe("Scanner", Ordered, func() { fsys.Add("The Beatles/Revolver/03 - I'm Only Sleeping.mp3", revolver(track(3, "I'm Only Sleeping"))) Expect(runScanner(ctx, false)).To(Succeed()) - Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(5))) + Expect(ds.MediaFile(ctx).CountAll()).To(Equal(int64(5))) mf, err := findByPath("The Beatles/Revolver/03 - I'm Only Sleeping.mp3") Expect(err).ToNot(HaveOccurred()) Expect(mf.Title).To(Equal("I'm Only Sleeping")) @@ -569,7 +305,7 @@ var _ = Describe("Scanner", Ordered, func() { fsys.UpdateTags("The Beatles/Revolver/02 - Eleanor Rigby.mp3", _t{"title": "Eleanor Rigby (remix)"}) Expect(runScanner(ctx, false)).To(Succeed()) - Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(4))) + Expect(ds.MediaFile(ctx).CountAll()).To(Equal(int64(4))) mf, _ := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") Expect(mf.Title).To(Equal("Eleanor Rigby (remix)")) }) @@ -578,7 +314,7 @@ var _ = Describe("Scanner", Ordered, func() { fsys.Add("The Beatles/Revolver/01 - Taxman.mp3", revolver(track(1, "Taxman", _t{"bitrate": 640}))) Expect(runScanner(ctx, false)).To(Succeed()) - Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(4))) + Expect(ds.MediaFile(ctx).CountAll()).To(Equal(int64(4))) mf, _ := findByPath("The Beatles/Revolver/01 - Taxman.mp3") Expect(mf.BitRate).To(Equal(640)) }) @@ -591,7 +327,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the file is marked as missing") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(3))) mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") @@ -612,14 +348,14 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the old file is not in the library") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(4))) _, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") Expect(err).To(MatchError(model.ErrNotFound)) By("Checking the new file is in the library") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, })).To(BeZero()) mf, err := findByPath("The Beatles/Help!/02 - Eleanor Rigby.mp3") @@ -641,7 +377,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(MatchError(ContainSubstring("I/O read error"))) By("Checking the both instances of the file are in the lib") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "Taxman"}, })).To(Equal(int64(2))) @@ -650,7 +386,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the old file is not in the library") - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "Taxman"}, }) Expect(err).ToNot(HaveOccurred()) @@ -671,14 +407,14 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the old file is not in the library") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, })).To(BeZero()) _, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") Expect(err).To(MatchError(model.ErrNotFound)) By("Checking the new file is in the library") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(4))) mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.flac") @@ -698,7 +434,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the file is marked as missing") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(3))) mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") @@ -712,7 +448,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the file is not marked as missing") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(4))) mf, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") @@ -737,7 +473,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking the file was found in the new folder") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(4))) mf, err = findByPath("The Beatles/Help!/02 - Eleanor Rigby.mp3") @@ -751,7 +487,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Verifying initial state has 5 tracks") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).To(Equal(int64(5))) @@ -790,77 +526,14 @@ var _ = Describe("Scanner", Ordered, func() { Expect(mf.Missing).To(BeFalse()) By("Verifying only 2 non-missing tracks remain (Help! tracks)") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ + Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, })).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().GetAll(ctx, 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().GetAll(ctx, model.QueryOptions{ + aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"name": "The Beatles"}, }) Expect(err).ToNot(HaveOccurred()) @@ -887,7 +560,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(mf.SortArtistName).To(BeEmpty()) By("Makingsure the artist in the DB has not changed") - aa, err = ds.Artist().GetAll(ctx, model.QueryOptions{ + aa, err = ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"name": "The Beatles"}, }) Expect(err).ToNot(HaveOccurred()) @@ -915,7 +588,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) By("Checking files are marked as missing but not deleted") - count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ + count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, }) Expect(err).ToNot(HaveOccurred()) @@ -943,7 +616,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking missing files are deleted") - count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ + count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, }) Expect(err).ToNot(HaveOccurred()) @@ -970,7 +643,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) By("Checking files are marked as missing but not deleted") - count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ + count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, }) Expect(err).ToNot(HaveOccurred()) @@ -992,7 +665,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) By("Checking missing files are deleted") - count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ + count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ Filters: squirrel.Eq{"missing": true}, }) Expect(err).ToNot(HaveOccurred()) @@ -1020,10 +693,10 @@ var _ = Describe("Scanner", Ordered, func() { simulateInterruptedScan := func(fullScan bool) { // Call ScanBegin to properly set LastScanStartedAt and FullScanInProgress // This simulates what would happen if a scan was interrupted (ScanBegin called but ScanEnd not) - Expect(ds.Library().ScanBegin(ctx, lib.ID, fullScan)).To(Succeed()) + Expect(ds.Library(ctx).ScanBegin(lib.ID, fullScan)).To(Succeed()) // Verify the update was persisted - reloaded, err := ds.Library().Get(ctx, lib.ID) + reloaded, err := ds.Library(ctx).Get(lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(reloaded.LastScanStartedAt).ToNot(BeZero()) Expect(reloaded.FullScanInProgress).To(Equal(fullScan)) @@ -1035,7 +708,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify files were imported - mfs, err := ds.MediaFile().GetAll(ctx) + mfs, err := ds.MediaFile(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) @@ -1056,7 +729,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify the comment was updated (which means the folder was processed and file re-imported) - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "Help!"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1071,7 +744,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify files were imported - mfs, err := ds.MediaFile().GetAll(ctx) + mfs, err := ds.MediaFile(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) @@ -1090,7 +763,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) // Verify the comment was updated (folder was processed despite unchanged hash) - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "Help!"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1105,12 +778,12 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify files were imported - mfs, err := ds.MediaFile().GetAll(ctx) + mfs, err := ds.MediaFile(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) // Library should have LastScanStartedAt cleared after successful scan - updatedLib, err := ds.Library().Get(ctx, lib.ID) + updatedLib, err := ds.Library(ctx).Get(lib.ID) Expect(err).ToNot(HaveOccurred()) Expect(updatedLib.LastScanStartedAt).To(BeZero()) Expect(updatedLib.FullScanInProgress).To(BeFalse()) @@ -1125,7 +798,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify the comment was updated - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "Help!"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1144,7 +817,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, false)).To(Succeed()) // Verify the comment was NOT updated (folder was skipped) - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "Help!"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1163,7 +836,7 @@ var _ = Describe("Scanner", Ordered, func() { refreshStatsCalls = nil // Create a mock artist repository that tracks RefreshStats calls - originalArtistRepo := ds.RealDS.Artist() + originalArtistRepo := ds.RealDS.Artist(ctx) ds.MockedArtist = &testArtistRepo{ ArtistRepository: originalArtistRepo, callTracker: &refreshStatsCalls, @@ -1209,7 +882,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(runScanner(ctx, true)).To(Succeed()) // Verify initial artist stats - should have 1 album, 1 song - artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"name": "The Beatles"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1228,7 +901,7 @@ var _ = Describe("Scanner", Ordered, func() { By("Verifying artist stats were updated correctly") // Fetch the artist again to check updated stats - artists, err = ds.Artist().GetAll(ctx, model.QueryOptions{ + artists, err = ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"name": "The Beatles"}, }) Expect(err).ToNot(HaveOccurred()) @@ -1247,58 +920,11 @@ var _ = Describe("Scanner", Ordered, func() { Expect(albumArtistStats.SongCount).To(Equal(3)) // 3 songs }) }) - - Context("when the database is busy", func() { - var busyDS *busyPersistDS - BeforeEach(func() { - // One album across many folders: the suite's single DB connection deadlocks phase 3 on many albums - album := template(_t{"albumartist": "Artist", "album": "Album"}) - files := fstest.MapFS{} - for i := range 30 { - files[fmt.Sprintf("Artist/Part %02d/%02d - Song.mp3", i, i+1)] = album(track(i+1, fmt.Sprintf("Song %02d", i+1))) - } - createFS(files) - busyDS = &busyPersistDS{MockDataStore: ds} - s = scanner.New(ctx, busyDS, events.NoopBroker(), - playlists.NewPlaylists(busyDS, artwork.NewUploader(busyDS)), metrics.NewNoopInstance()) - }) - - It("gives up and stops walking the library when the database stays busy", func() { - busyDS.failures.Store(1000) - - Expect(runScanner(ctx, true)).To(MatchError(ContainSubstring("database is locked"))) - - Expect(mfRepo.cursorCalls.Load()).To(BeNumerically("<", 30)) - }) - - It("does not mark unvisited folders missing when the scan gives up", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - busyDS.failures.Store(1000) - - Expect(runScanner(ctx, true)).ToNot(Succeed()) - - Expect(ds.Folder().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero()) - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero()) - }) - }) }) -// busyPersistDS fails the scanner's folder saves with SQLITE_BUSY, as if WithTxRetry ran out of retries. -type busyPersistDS struct { - *tests.MockDataStore - failures atomic.Int32 -} - -func (b *busyPersistDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, label ...string) error { - if len(label) > 0 && label[0] == "scanner: persist changes" && b.failures.Add(-1) >= 0 { - return sqlite3.Error{Code: sqlite3.ErrBusy} - } - return b.MockDataStore.WithTxRetry(ctx, block, label...) -} - func createFindByPath(ctx context.Context, ds model.DataStore) func(string) (*model.MediaFile, error) { return func(path string) (*model.MediaFile, error) { - list, err := ds.MediaFile().FindByPaths(ctx, []string{path}) + list, err := ds.MediaFile(ctx).FindByPaths([]string{path}) if err != nil { return nil, err } @@ -1312,19 +938,13 @@ func createFindByPath(ctx context.Context, ds model.DataStore) func(string) (*mo type mockMediaFileRepo struct { model.MediaFileRepository GetMissingAndMatchingError error - cursorCalls atomic.Int32 } -func (m *mockMediaFileRepo) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.MediaFileCursor, error) { - m.cursorCalls.Add(1) - return m.MediaFileRepository.GetCursor(ctx, options...) -} - -func (m *mockMediaFileRepo) GetMissingAndMatching(ctx context.Context, libId int) (model.MediaFileCursor, error) { +func (m *mockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) { if m.GetMissingAndMatchingError != nil { return nil, m.GetMissingAndMatchingError } - return m.MediaFileRepository.GetMissingAndMatching(ctx, libId) + return m.MediaFileRepository.GetMissingAndMatching(libId) } type testArtistRepo struct { @@ -1332,7 +952,7 @@ type testArtistRepo struct { callTracker *[]bool } -func (m *testArtistRepo) RefreshStats(ctx context.Context, allArtists bool) (int64, error) { +func (m *testArtistRepo) RefreshStats(allArtists bool) (int64, error) { *m.callTracker = append(*m.callTracker, allArtists) - return m.ArtistRepository.RefreshStats(ctx, allArtists) + return m.ArtistRepository.RefreshStats(allArtists) } diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 1864a6a44..e6a694f2b 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -5,13 +5,11 @@ 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" @@ -53,9 +51,6 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<- // Recursively walk this folder and all its children err = walkFolder(ctx, job, folderPath, checker, results) - if utils.IsCtxDone(ctx) { - return - } if err != nil { log.Error(ctx, "Scanner: Error walking target folder", "path", folderPath, err) continue @@ -85,17 +80,14 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker dir := path.Clean(currentFolder) log.Trace(ctx, "Scanner: Found directory", " path", dir, "audioFiles", maps.Keys(folder.audioFiles), - "images", maps.Keys(folder.imageFiles), "playlists", len(folder.playlistFiles), "imagesUpdatedAt", folder.imagesUpdatedAt, + "images", maps.Keys(folder.imageFiles), "playlists", folder.numPlaylists, "imagesUpdatedAt", folder.imagesUpdatedAt, "updTime", folder.updTime, "modTime", folder.modTime, "numChildren", len(children)) folder.path = dir folder.elapsed.Start() - select { - case results <- folder: - return nil - case <-ctx.Done(): - return ctx.Err() - } + results <- folder + + return nil } func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreChecker) (folder *folderEntry, children []string, err error) { @@ -131,6 +123,9 @@ 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() } @@ -140,10 +135,7 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC log.Warn(ctx, "Scanner: Invalid symlink", "dir", entryPath, err) continue } - if isIgnoredEntry(entry.Name(), isDir) { - continue - } - if isDir && isDirReadable(ctx, job.fs, entryPath) { + if isDir && !isDirIgnored(entry.Name()) && isDirReadable(ctx, job.fs, entryPath) { children = append(children, entryPath) folder.numSubFolders++ } else { @@ -155,16 +147,12 @@ 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(name): + case model.IsAudioFile(entry.Name()): folder.audioFiles[entry.Name()] = entry - case model.IsValidPlaylist(name): - folder.playlistFiles[entry.Name()] = entry - case model.IsImageFile(name): + case model.IsValidPlaylist(entry.Name()): + folder.numPlaylists++ + case model.IsImageFile(entry.Name()): folder.imageFiles[entry.Name()] = entry folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) } @@ -225,59 +213,6 @@ 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) @@ -297,37 +232,23 @@ var ignoredDirs = []string{ "$RECYCLE.BIN", "#snapshot", "@Recycle", - "@eaDir", "@Recently-Snapshot", - ".git", ".streams", "lost+found", } -// 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) { +// 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, "..") { return true } - return isDotEntry(name) && (!isDir || conf.Server.Scanner.IgnoreDotFolders) + if slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) { + return true + } + return false } -// 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, "..") +func isEntryIgnored(name string) bool { + return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") } diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 43939e5c2..42b7af7ba 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -45,14 +45,6 @@ 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{ @@ -101,52 +93,15 @@ 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, 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), + Entry("with symlinks enabled", true, 7), + Entry("with symlinks disabled", false, 6), ) }) @@ -309,308 +264,16 @@ 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("dot-prefixed album dir", ".Hack Sign Original Soundtrack", false), - Entry("git dir", ".git", true), - Entry("streams dir", ".streams", true), + Entry("hidden dir", ".hidden_folder", true), Entry("dir starting with ellipsis", "...unhidden_folder", false), Entry("recycle bin", "$Recycle.Bin", true), Entry("snapshot dir", "#snapshot", true), - Entry("synology metadata dir", "@eaDir", 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), ) }) @@ -751,30 +414,3 @@ 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 1ac5468f0..376db910c 100644 --- a/scanner/watcher.go +++ b/scanner/watcher.go @@ -5,7 +5,6 @@ import ( "fmt" "io/fs" "path/filepath" - "strings" "sync" "time" @@ -23,7 +22,7 @@ type Watcher interface { } type watcher struct { - mainCtx context.Context //nolint:containedctx // watcher lifecycle ctx + mainCtx context.Context ds model.DataStore scanner model.Scanner triggerWait time.Duration @@ -60,7 +59,7 @@ func (w *watcher) Run(ctx context.Context) error { w.mainCtx = ctx // Start watchers for all existing libraries - libs, err := w.ds.Library().GetAll(ctx) + libs, err := w.ds.Library(ctx).GetAll() if err != nil { return fmt.Errorf("getting libraries: %w", err) } @@ -321,35 +320,18 @@ func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.Music } func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool { - _, 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 - } + baseDir, name := filepath.Split(path) switch { - 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 model.IsAudioFile(path): + return false + case model.IsValidPlaylist(path): + return false + case model.IsImageFile(path): + return false 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 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 + // 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) } diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index 12c94602c..9795129b0 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -9,7 +9,6 @@ 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" @@ -26,20 +25,15 @@ var _ = Describe("Watcher", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - // Must dwarf the 20ms Consistently windows below, or a loaded runner's delayed timer reset flakes the debouncing spec. - conf.Server.Scanner.WatcherWait = 200 * time.Millisecond + conf.Server.Scanner.WatcherWait = 50 * time.Millisecond // Short wait for tests 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: "fake-watcher:///test/library", + Path: "/test/library", } // Set up mocks @@ -92,7 +86,7 @@ var _ = Describe("Watcher", func() { return nil } return calls[0].Targets - }, 2*time.Second, 10*time.Millisecond).Should(HaveLen(2)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) // Verify targets calls := mockScanner.GetScanFoldersCalls() @@ -112,7 +106,7 @@ var _ = Describe("Watcher", func() { // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Verify the target calls := mockScanner.GetScanFoldersCalls() @@ -130,7 +124,7 @@ var _ = Describe("Watcher", func() { // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Verify only one target despite multiple file/folder changes calls := mockScanner.GetScanFoldersCalls() @@ -171,7 +165,7 @@ var _ = Describe("Watcher", func() { // Now wait for the debounce timer to expire and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) }) It("triggers scan after quiet period", func() { @@ -184,7 +178,7 @@ var _ = Describe("Watcher", func() { // Wait for quiet period Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) }) }) @@ -206,7 +200,7 @@ var _ = Describe("Watcher", func() { // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Should scan the library root calls := mockScanner.GetScanFoldersCalls() @@ -223,7 +217,7 @@ var _ = Describe("Watcher", func() { // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Should have only one target calls := mockScanner.GetScanFoldersCalls() @@ -240,7 +234,7 @@ var _ = Describe("Watcher", func() { lib2 = &model.Library{ ID: 2, Name: "Test Library 2", - Path: "fake-watcher:///test/library2", + Path: "/test/library2", } mockLibRepo := mockDS.MockedLibrary.(*tests.MockLibraryRepo) @@ -267,7 +261,7 @@ var _ = Describe("Watcher", func() { return nil } return calls[0].Targets - }, 2*time.Second, 10*time.Millisecond).Should(HaveLen(2)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) // Verify library IDs are different calls := mockScanner.GetScanFoldersCalls() @@ -434,50 +428,6 @@ 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 b616f0884..b1e26f1de 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 := range 63 { + for i := 0; i < 63; i++ { if v&(1< 0 { - p.Errors = &fieldErrors - } - w.Header().Set("Content-Type", problemContentType) - w.WriteHeader(status) - if err := json.NewEncoder(w).Encode(p); err != nil { - log.Warn(r.Context(), "API v1: could not write problem response", err) - } -} - -func bindingErrorHandler(w http.ResponseWriter, r *http.Request, err error) { - var fieldErrors []ValidationError - var required *RequiredParamError - var invalid *InvalidParamFormatError - var tooMany *TooManyValuesForParamError - var unmarshal *UnmarshalingParamError - switch { - case errors.As(err, &required): - fieldErrors = append(fieldErrors, ValidationError{Field: required.ParamName, Message: "is required"}) - case errors.As(err, &invalid): - fieldErrors = append(fieldErrors, ValidationError{Field: invalid.ParamName, Message: invalid.Err.Error()}) - case errors.As(err, &tooMany): - fieldErrors = append(fieldErrors, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"}) - case errors.As(err, &unmarshal): - fieldErrors = append(fieldErrors, ValidationError{Field: unmarshal.ParamName, Message: unmarshal.Err.Error()}) - } - writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, err.Error(), fieldErrors...) -} diff --git a/server/apiv1/problem_test.go b/server/apiv1/problem_test.go deleted file mode 100644 index 256296b3c..000000000 --- a/server/apiv1/problem_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package apiv1 - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func decodeProblem(w *httptest.ResponseRecorder) Problem { - var p Problem - ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), &p)).To(Succeed()) - return p -} - -var _ = Describe("problem", func() { - var w *httptest.ResponseRecorder - var r *http.Request - - BeforeEach(func() { - w = httptest.NewRecorder() - r = httptest.NewRequest(http.MethodGet, "/api/v1/server", nil) - }) - - Describe("writeProblem", func() { - DescribeTable("maps domain errors to status and code", - func(err error, status int, code ProblemCode) { - writeProblem(w, r, err) - Expect(w.Code).To(Equal(status)) - Expect(w.Header().Get("Content-Type")).To(Equal(problemContentType)) - p := decodeProblem(w) - Expect(p.Status).To(Equal(status)) - Expect(p.Code).To(Equal(code)) - Expect(p.Title).To(Equal(http.StatusText(status))) - Expect(p.Type).To(BeNil()) - Expect(w.Body.String()).ToNot(ContainSubstring(`"type"`)) - }, - Entry("not found", model.ErrNotFound, http.StatusNotFound, ProblemCodeNotFound), - Entry("not authorized", model.ErrNotAuthorized, http.StatusForbidden, ProblemCodeForbidden), - Entry("invalid auth", model.ErrInvalidAuth, http.StatusUnauthorized, ProblemCodeUnauthorized), - Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized), - Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation), - Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable), - Entry("unknown", errors.New("boom"), http.StatusInternalServerError, ProblemCodeInternal), - ) - - DescribeTable("keeps the wrapping context as detail for client errors", - func(err error) { - writeProblem(w, r, err) - p := decodeProblem(w) - Expect(p.Status).To(Equal(http.StatusNotFound)) - Expect(p.Detail).ToNot(BeNil()) - Expect(*p.Detail).To(ContainSubstring("album 123")) - }, - Entry("fmt.Errorf %w", fmt.Errorf("album 123: %w", model.ErrNotFound)), - Entry("errors.Join", errors.Join(errors.New("album 123"), model.ErrNotFound)), - ) - - It("hides details for internal errors", func() { - writeProblem(w, r, errors.New("db password is hunter2")) - p := decodeProblem(w) - Expect(p.Detail).To(BeNil()) - }) - }) - - Describe("writeProblemStatus", func() { - It("writes field errors only when provided", func() { - writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "bad input", - ValidationError{Field: "limit", Message: "must be <= 2000"}) - p := decodeProblem(w) - Expect(p.Errors).ToNot(BeNil()) - Expect(*p.Errors).To(HaveLen(1)) - Expect((*p.Errors)[0].Field).To(Equal("limit")) - }) - - It("omits detail when empty", func() { - writeProblemStatus(w, r, http.StatusMethodNotAllowed, ProblemCodeMethodNotAllowed, "") - Expect(w.Body.String()).ToNot(ContainSubstring(`"detail"`)) - Expect(w.Body.String()).ToNot(ContainSubstring(`"errors"`)) - }) - }) - - Describe("bindingErrorHandler", func() { - DescribeTable("maps parameter binding errors to a validation problem with the field", - func(err error, field, message string) { - bindingErrorHandler(w, r, err) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - p := decodeProblem(w) - Expect(p.Code).To(Equal(ProblemCodeValidation)) - Expect(p.Errors).ToNot(BeNil()) - Expect(*p.Errors).To(HaveLen(1)) - Expect((*p.Errors)[0].Field).To(Equal(field)) - Expect((*p.Errors)[0].Message).To(ContainSubstring(message)) - }, - Entry("required", &RequiredParamError{ParamName: "limit"}, "limit", "is required"), - Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New("not a number")}, "offset", "not a number"), - Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "single value"), - Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "bad json"), - ) - - It("still returns a validation problem for unknown binding errors", func() { - bindingErrorHandler(w, r, errors.New("weird")) - p := decodeProblem(w) - Expect(p.Status).To(Equal(http.StatusBadRequest)) - Expect(p.Code).To(Equal(ProblemCodeValidation)) - Expect(p.Errors).To(BeNil()) - }) - }) -}) diff --git a/server/apiv1/server_info.go b/server/apiv1/server_info.go deleted file mode 100644 index 458363efe..000000000 --- a/server/apiv1/server_info.go +++ /dev/null @@ -1,23 +0,0 @@ -package apiv1 - -import ( - "context" - "fmt" - - "github.com/navidrome/navidrome/api" - "github.com/navidrome/navidrome/consts" -) - -func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObject) (GetServerInfoResponseObject, error) { - count, err := rt.ds.User().CountAll(ctx) - if err != nil { - return nil, fmt.Errorf("counting users: %w", err) - } - return GetServerInfo200JSONResponse{ - Name: "Navidrome", - ServerVersion: consts.Version, - SpecVersion: api.SpecVersion(), - SetupRequired: count == 0, - LoginMethods: []ServerInfoLoginMethods{ServerInfoLoginMethodsPassword}, - }, nil -} diff --git a/server/apiv1/server_info_test.go b/server/apiv1/server_info_test.go deleted file mode 100644 index b9dae6f8d..000000000 --- a/server/apiv1/server_info_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package apiv1 - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/api" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("GET /server", func() { - var ctx context.Context - var ds *tests.MockDataStore - var users *tests.MockedUserRepo - - BeforeEach(func() { - ctx = GinkgoT().Context() - users = tests.CreateMockUserRepo() - ds = &tests.MockDataStore{MockedUser: users} - }) - - get := func() (*httptest.ResponseRecorder, ServerInfo) { - w := serve(New(ds), httptest.NewRequest(http.MethodGet, "/api/v1/server", nil)) - var info ServerInfo - if w.Code == http.StatusOK { - ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - } - return w, info - } - - It("describes the server with setupRequired when there are no users", func() { - w, info := get() - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(HavePrefix("application/json")) - Expect(info.Name).To(Equal("Navidrome")) - Expect(info.ServerVersion).To(Equal(consts.Version)) - Expect(info.SpecVersion).To(Equal(api.SpecVersion())) - Expect(info.SetupRequired).To(BeTrue()) - Expect(info.LoginMethods).To(ConsistOf(ServerInfoLoginMethodsPassword)) - }) - - It("reports setupRequired=false once a user exists", func() { - Expect(users.Put(ctx, &model.User{ID: "u1", UserName: "admin", IsAdmin: true})).To(Succeed()) - _, info := get() - Expect(info.SetupRequired).To(BeFalse()) - }) - - It("returns a 500 problem when the user count fails", func() { - users.Error = errors.New("db down") - w, _ := get() - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInternal)) - }) -}) diff --git a/server/apiv1/spec.go b/server/apiv1/spec.go deleted file mode 100644 index bdca19693..000000000 --- a/server/apiv1/spec.go +++ /dev/null @@ -1,51 +0,0 @@ -package apiv1 - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "path" - "strconv" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/req" - "github.com/zeebo/xxh3" -) - -func specHandler(body []byte, contentType string) http.HandlerFunc { - etag := fmt.Sprintf("%016x", xxh3.Hash(body)) - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("ETag", `"`+etag+`"`) - w.Header().Set("Cache-Control", "no-cache") - if req.IfNoneMatch(r, etag) { - w.WriteHeader(http.StatusNotModified) - return - } - w.Header().Set("Content-Type", contentType) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - } -} - -// withBasePath adds BasePath to the advertised server URL, since only the running server knows it. -func withBasePath(body []byte, key string, quotedInBundle bool) []byte { - serverURL := path.Join(conf.Server.BasePath, consts.URLPathAPIv1) - if serverURL == consts.URLPathAPIv1 { - return body - } - oldURL := consts.URLPathAPIv1 - if quotedInBundle { - oldURL = strconv.Quote(oldURL) - } - old := []byte(key + oldURL) - if bytes.Count(body, old) != 1 { - log.Error("API v1: server URL not found in the bundled spec, serving it without the base path", "key", key) - return body - } - // A JSON string is also a valid YAML double-quoted scalar, so one encoding escapes both formats. - newURL, _ := json.Marshal(serverURL) - return bytes.Replace(body, old, append([]byte(key), newURL...), 1) -} diff --git a/server/apiv1/spec_test.go b/server/apiv1/spec_test.go deleted file mode 100644 index a2368f899..000000000 --- a/server/apiv1/spec_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package apiv1 - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/api" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "gopkg.in/yaml.v3" -) - -var _ = Describe("OpenAPI document routes", func() { - var router *Router - - BeforeEach(func() { - router = New(&tests.MockDataStore{}) - }) - - get := func(path string, headers map[string]string) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, path, nil) - for k, v := range headers { - req.Header.Set(k, v) - } - return serve(router, req) - } - - DescribeTable("serves the embedded bundle", - func(path, contentType string, body []byte) { - w := get(path, nil) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal(contentType)) - Expect(w.Header().Get("ETag")).ToNot(BeEmpty()) - Expect(w.Header().Get("Cache-Control")).To(Equal("no-cache")) - Expect(w.Body.Bytes()).To(Equal(body)) - }, - Entry("JSON", "/api/v1/openapi.json", "application/json", api.SpecJSON()), - Entry("YAML", "/api/v1/openapi.yaml", "application/yaml", api.SpecYAML()), - ) - - DescribeTable("revalidates with If-None-Match", - func(ifNoneMatch func(etag string) string, expected int) { - etag := get("/api/v1/openapi.json", nil).Header().Get("ETag") - w := get("/api/v1/openapi.json", map[string]string{"If-None-Match": ifNoneMatch(etag)}) - Expect(w.Code).To(Equal(expected)) - if expected == http.StatusNotModified { - Expect(w.Body.Len()).To(BeZero()) - Expect(w.Header().Get("ETag")).To(Equal(etag)) - } else { - Expect(w.Body.Bytes()).To(Equal(api.SpecJSON())) - } - }, - Entry("exact ETag", func(etag string) string { return etag }, http.StatusNotModified), - Entry("ETag in a list", func(etag string) string { return `"other", ` + etag }, http.StatusNotModified), - Entry("weak ETag", func(etag string) string { return "W/" + etag }, http.StatusNotModified), - Entry("wildcard", func(string) string { return "*" }, http.StatusNotModified), - Entry("stale ETag", func(string) string { return `"stale"` }, http.StatusOK), - ) - - It("ignores Range and returns the full document", func() { - w := get("/api/v1/openapi.json", map[string]string{"Range": "bytes=0-9"}) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Accept-Ranges")).To(BeEmpty()) - Expect(w.Body.Bytes()).To(Equal(api.SpecJSON())) - }) - - It("uses different ETags for JSON and YAML", func() { - j := get("/api/v1/openapi.json", nil) - y := get("/api/v1/openapi.yaml", nil) - Expect(j.Header().Get("ETag")).ToNot(Equal(y.Header().Get("ETag"))) - }) - - Describe("with a base path", func() { - decode := func(format string, body []byte) map[string]any { - var doc map[string]any - if format == "json" { - ExpectWithOffset(1, json.Unmarshal(body, &doc)).To(Succeed()) - } else { - ExpectWithOffset(1, yaml.Unmarshal(body, &doc)).To(Succeed()) - } - return doc - } - serverURL := func(doc map[string]any) any { - return doc["servers"].([]any)[0].(map[string]any)["url"] - } - var plainETag string - - BeforeEach(func() { - plainETag = get("/api/v1/openapi.json", nil).Header().Get("ETag") - DeferCleanup(configtest.SetupConfig()) - conf.Server.BasePath = "/music" - router = New(&tests.MockDataStore{}) - }) - - DescribeTable("advertises the server under the base path and changes nothing else", - func(path, format string, bundle []byte) { - w := get(path, nil) - Expect(w.Code).To(Equal(http.StatusOK)) - served, original := decode(format, w.Body.Bytes()), decode(format, bundle) - Expect(serverURL(served)).To(Equal("/music/api/v1")) - delete(served, "servers") - delete(original, "servers") - Expect(served).To(Equal(original)) - }, - Entry("JSON", "/api/v1/openapi.json", "json", api.SpecJSON()), - Entry("YAML", "/api/v1/openapi.yaml", "yaml", api.SpecYAML()), - ) - - It("uses its own ETag, and still revalidates", func() { - etag := get("/api/v1/openapi.json", nil).Header().Get("ETag") - Expect(etag).ToNot(Equal(plainETag)) - Expect(get("/api/v1/openapi.json", map[string]string{"If-None-Match": etag}).Code).To(Equal(http.StatusNotModified)) - }) - - It("escapes base paths that need quoting", func() { - conf.Server.BasePath = "/my music: \"live\"" - router = New(&tests.MockDataStore{}) - Expect(serverURL(decode("json", get("/api/v1/openapi.json", nil).Body.Bytes()))).To(Equal("/my music: \"live\"/api/v1")) - Expect(serverURL(decode("yaml", get("/api/v1/openapi.yaml", nil).Body.Bytes()))).To(Equal("/my music: \"live\"/api/v1")) - }) - }) - - DescribeTable("the bundle advertises the API path exactly once, which the base-path rewrite relies on", - func(bundle []byte) { - Expect(bytes.Count(bundle, []byte(consts.URLPathAPIv1))).To(Equal(1)) - }, - Entry("JSON", api.SpecJSON()), - Entry("YAML", api.SpecYAML()), - ) -}) diff --git a/server/auth.go b/server/auth.go index 3e58359da..a7edaab0a 100644 --- a/server/auth.go +++ b/server/auth.go @@ -12,12 +12,10 @@ import ( "net/http" "slices" "strings" - "sync" "time" "github.com/deluan/rest" "github.com/go-chi/jwtauth/v5" - "github.com/lestrrat-go/jwx/v3/jwt" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -49,7 +47,7 @@ func login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { } func doLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) { - user, err := validateLogin(r.Context(), ds.User(), username, password) + user, err := validateLogin(ds.User(r.Context()), username, password) if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again") return @@ -96,16 +94,6 @@ func buildAuthPayload(user *model.User) map[string]any { return payload } -// MaxLoginBodySize bounds the payload of unauthenticated login routes across all APIs. -const MaxLoginBodySize = 8 << 10 - -func LimitLoginBody(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, MaxLoginBodySize) - next.ServeHTTP(w, r) - }) -} - func getCredentialsFromBody(r *http.Request) (username string, password string, err error) { data := make(map[string]string) decoder := json.NewDecoder(r.Body) @@ -127,7 +115,7 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) return } - c, err := ds.User().CountAll(r.Context()) + c, err := ds.User(r.Context()).CountAll() if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) return @@ -147,6 +135,7 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error { log.Warn(ctx, "Creating initial user", "user", username) + now := time.Now() caser := cases.Title(language.Und) initialUser := model.User{ ID: id.NewRandom(), @@ -155,18 +144,17 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password Email: "", NewPassword: password, IsAdmin: true, - LastLoginAt: new(time.Now()), + LastLoginAt: &now, } - err := ds.User().Put(ctx, &initialUser) + err := ds.User(ctx).Put(&initialUser) if err != nil { - log.Error(ctx, "Could not create initial user", "user", initialUser.UserName, err) - return fmt.Errorf("creating initial user: %w", err) + log.Error(ctx, "Could not create initial user", "user", initialUser, err) } return nil } -func validateLogin(ctx context.Context, userRepo model.UserRepository, userName, password string) (*model.User, error) { - u, err := userRepo.FindByUsernameWithPassword(ctx, userName) +func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) { + u, err := userRepo.FindByUsernameWithPassword(userName) if errors.Is(err, model.ErrNotFound) { return nil, nil } @@ -176,9 +164,9 @@ func validateLogin(ctx context.Context, userRepo model.UserRepository, userName, if u.Password != password { return nil, nil } - err = userRepo.UpdateLastLoginAt(ctx, u.ID) + err = userRepo.UpdateLastLoginAt(u.ID) if err != nil { - log.Error(ctx, "Could not update LastLoginAt", "user", userName) + log.Error("Could not update LastLoginAt", "user", userName) } return u, nil } @@ -218,14 +206,14 @@ func UsernameFromExtAuthHeader(r *http.Request) string { log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.") return "" } - username := r.Header.Get(conf.Server.ExtAuth.UserHeader) - if username == "" { - return "" - } if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) { log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) return "" } + username := r.Header.Get(conf.Server.ExtAuth.UserHeader) + if username == "" { + return "" + } log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username) return username } @@ -244,7 +232,7 @@ func UsernameFromConfig(*http.Request) string { } func contextWithUser(ctx context.Context, ds model.DataStore, username string) (context.Context, error) { - user, err := ds.User().FindByUsername(ctx, username) + user, err := ds.User(ctx).FindByUsername(username) if err == nil { ctx = log.NewContext(ctx, "username", username) ctx = request.WithUsername(ctx, user.UserName) @@ -273,7 +261,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader) - if err != nil || !tokenAllowed(ctx) { + if err != nil { _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") return } @@ -283,88 +271,24 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { } } -// tokenAllowed re-checks a JWT that actually identifies the resolved user. Header and -// config auth carry no token, so they short-circuit to true. -func tokenAllowed(ctx context.Context) bool { - token, _, err := jwtauth.FromContext(ctx) - if err != nil || token == nil { - return true - } - usr, ok := request.UserFrom(ctx) - if !ok { - return true - } - claims := auth.ClaimsFromToken(token) - if !strings.EqualFold(claims.Subject, usr.UserName) { - return true - } - if err := auth.CheckClaims(claims, usr, auth.AudienceNative); err != nil { - log.Warn(ctx, "Native API: rejected token", "user", claims.Subject, err) - return false - } - return true -} - -// refreshingWriter defers the refreshed-token header until the handler's first write, so an -// epoch the handler bumped reaches the token the client stores. -type refreshingWriter struct { - http.ResponseWriter - ctx context.Context //nolint:containedctx // ResponseWriter wrapper defers work to Write, which has no ctx - token jwt.Token - once sync.Once -} - -func (w *refreshingWriter) setToken() { - w.once.Do(func() { - claims := auth.ClaimsFromToken(w.token) - if epoch, ok := request.TokenEpochFrom(w.ctx); ok { - claims.Epoch = epoch - } - newToken, err := auth.TouchClaims(claims) - if err != nil { - log.Error(w.ctx, "Could not sign new token", err) - return - } - w.Header().Set(consts.UIAuthorizationHeader, newToken) - }) -} - -func (w *refreshingWriter) WriteHeader(code int) { - w.setToken() - w.ResponseWriter.WriteHeader(code) -} - -func (w *refreshingWriter) Write(b []byte) (int, error) { - w.setToken() - return w.ResponseWriter.Write(b) -} - -// Flush keeps the SSE events route working through the wrap. -func (w *refreshingWriter) Flush() { - w.setToken() - if f, ok := w.ResponseWriter.(http.Flusher); ok { - f.Flush() - } -} - -// Unwrap lets capability lookups, such as SSE's write deadline, see past this wrap. -func (w *refreshingWriter) Unwrap() http.ResponseWriter { - return w.ResponseWriter -} - -// JWTRefresher updates the expiry date of the received JWT token, and adds the new one to -// the Authorization Header. +// JWTRefresher updates the expiry date of the received JWT token, and add the new one to the Authorization Header func JWTRefresher(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, _, err := jwtauth.FromContext(r.Context()) - if err != nil || token == nil { + ctx := r.Context() + token, _, err := jwtauth.FromContext(ctx) + if err != nil { next.ServeHTTP(w, r) return } - ctx := request.WithTokenEpochHolder(r.Context()) - rw := &refreshingWriter{ResponseWriter: w, ctx: ctx, token: token} - next.ServeHTTP(rw, r.WithContext(ctx)) - rw.setToken() + newTokenString, err := auth.TouchToken(token) + if err != nil { + log.Error(r, "Could not sign new token", err) + _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") + return + } + + w.Header().Set(consts.UIAuthorizationHeader, newTokenString) + next.ServeHTTP(w, r) }) } @@ -377,13 +301,12 @@ func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]any } } - ctx := r.Context() - userRepo := ds.User() - user, err := userRepo.FindByUsernameWithPassword(ctx, username) + userRepo := ds.User(r.Context()) + user, err := userRepo.FindByUsernameWithPassword(username) if user == nil || err != nil { log.Info(r, "User passed in header not found", "user", username) // Check if this is the first user being created - count, _ := userRepo.CountAll(ctx) + count, _ := userRepo.CountAll() isFirstUser := count == 0 newUser := model.User{ @@ -394,19 +317,19 @@ func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]any NewPassword: consts.PasswordAutogenPrefix + id.NewRandom(), IsAdmin: isFirstUser, // Make the first user an admin } - err := userRepo.Put(ctx, &newUser) + err := userRepo.Put(&newUser) if err != nil { log.Error(r, "Could not create new user", "user", username, err) return nil } - user, err = userRepo.FindByUsernameWithPassword(ctx, username) + user, err = userRepo.FindByUsernameWithPassword(username) if user == nil || err != nil { log.Error(r, "Created user but failed to fetch it", "user", username) return nil } } - err = userRepo.UpdateLastLoginAt(ctx, user.ID) + err = userRepo.UpdateLastLoginAt(user.ID) if err != nil { log.Error(r, "Could not update LastLoginAt", "user", username, err) return nil diff --git a/server/auth_test.go b/server/auth_test.go index 1095fafc9..f6af6f0d6 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -4,7 +4,6 @@ import ( "context" "crypto/md5" "encoding/json" - "errors" "fmt" "net/http" "net/http/httptest" @@ -13,27 +12,17 @@ import ( "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" - "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" - "github.com/sirupsen/logrus" - "github.com/sirupsen/logrus/hooks/test" ) var _ = Describe("Auth", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - Describe("User login", func() { var ds model.DataStore var req *http.Request @@ -54,8 +43,8 @@ var _ = Describe("Auth", func() { }) It("creates an admin user with the specified password", func() { - usr := ds.User() - u, err := usr.FindByUsername(ctx, "johndoe") + usr := ds.User(context.Background()) + u, err := usr.FindByUsername("johndoe") Expect(err).To(BeNil()) Expect(u.Password).ToNot(BeEmpty()) Expect(u.IsAdmin).To(BeTrue()) @@ -74,26 +63,6 @@ var _ = Describe("Auth", func() { }) }) - Describe("createAdminUser", func() { - It("returns the error when the user cannot be saved", func() { - ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}} - err := createAdminUser(context.Background(), ds, "johndoe", "secret") - Expect(err).To(MatchError(ContainSubstring("db is down"))) - }) - }) - - Describe("createAdmin when the user cannot be stored", func() { - It("responds 500 rather than falling through to login", func() { - failing := dsWithFailingPut(errors.New("db is down")) - req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"johndoe", "password":"secret"}`)) - resp = httptest.NewRecorder() - - createAdmin(failing)(resp, req) - - Expect(resp.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - Describe("Login from HTTP headers", func() { const ( trustedIpv4 = "192.168.0.42" @@ -105,8 +74,8 @@ var _ = Describe("Auth", func() { fs := os.DirFS("tests/fixtures") BeforeEach(func() { - usr := ds.User() - _ = usr.Put(ctx, &model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) + usr := ds.User(context.Background()) + _ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) req = httptest.NewRequest("GET", "/index.html", nil) req.Header.Add("Remote-User", "janedoe") resp = httptest.NewRecorder() @@ -230,16 +199,9 @@ var _ = Describe("Auth", func() { Expect(resp.Code).To(Equal(http.StatusUnauthorized)) }) - It("rejects a request body larger than the limit", func() { - body := `{"username":"janedoe", "password":"abc123", "padding":"` + strings.Repeat("x", MaxLoginBodySize) + `"}` - req = httptest.NewRequest("POST", "/login", strings.NewReader(body)) - LimitLoginBody(http.HandlerFunc(login(ds))).ServeHTTP(resp, req) - Expect(resp.Code).To(Equal(http.StatusUnprocessableEntity)) - }) - It("logs in successfully if user exists", func() { - usr := ds.User() - _ = usr.Put(ctx, &model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) + usr := ds.User(context.Background()) + _ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) login(ds)(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) @@ -255,56 +217,6 @@ var _ = Describe("Auth", func() { }) }) - Describe("UsernameFromExtAuthHeader", func() { - var hook *test.Hook - var r *http.Request - - BeforeEach(func() { - conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16" - prevLevel := log.CurrentLevel() - l, h := test.NewNullLogger() - hook = h - prevLogger := log.SetDefaultLogger(l) - log.SetLevel(log.LevelWarn) - DeferCleanup(func() { - log.SetDefaultLogger(prevLogger) - log.SetLevel(prevLevel) - }) - r = httptest.NewRequest("GET", "/", nil) - }) - - warnings := func() []*logrus.Entry { - var ws []*logrus.Entry - for _, e := range hook.AllEntries() { - if e.Level == logrus.WarnLevel { - ws = append(ws, e) - } - } - return ws - } - - It("returns the username from a trusted source", func() { - r.Header.Set("Remote-User", "janedoe") - r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.0.42")) - Expect(UsernameFromExtAuthHeader(r)).To(Equal("janedoe")) - Expect(warnings()).To(BeEmpty()) - }) - - It("does not warn when an untrusted source sends no user header", func() { - r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8")) - Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty()) - Expect(warnings()).To(BeEmpty()) - }) - - It("warns when an untrusted source sends the user header", func() { - r.Header.Set("Remote-User", "janedoe") - r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8")) - Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty()) - Expect(warnings()).To(HaveLen(1)) - Expect(warnings()[0].Message).To(Equal("IP is not whitelisted for external authentication")) - }) - }) - Describe("tokenFromHeader", func() { It("returns the token when the Authorization header is set correctly", func() { req := httptest.NewRequest("GET", "/", nil) @@ -403,14 +315,14 @@ var _ = Describe("Auth", func() { Expect(result["isAdmin"]).To(BeTrue()) // Verify user was created as admin - u, err := ds.User().FindByUsername(ctx, "firstuser") + u, err := ds.User(context.Background()).FindByUsername("firstuser") Expect(err).To(BeNil()) Expect(u.IsAdmin).To(BeTrue()) }) It("does not make subsequent users admins", func() { // Create the first user - _ = ds.User().Put(ctx, &model.User{ + _ = ds.User(context.Background()).Put(&model.User{ ID: "existing-user-id", UserName: "existinguser", Name: "Existing User", @@ -425,143 +337,9 @@ var _ = Describe("Auth", func() { Expect(result["isAdmin"]).To(BeFalse()) // Verify user was created as non-admin - u, err := ds.User().FindByUsername(ctx, "seconduser") + u, err := ds.User(context.Background()).FindByUsername("seconduser") Expect(err).To(BeNil()) Expect(u.IsAdmin).To(BeFalse()) }) }) - - Describe("Authenticator token gating", func() { - var ds *tests.MockDataStore - var usr *model.User - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SessionTimeout = time.Hour - ds = &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - usr = &model.User{ID: "u1", UserName: "johndoe", NewPassword: "pw", TokenEpoch: 2} - Expect(ur.Put(ctx, usr)).To(Succeed()) - }) - - serve := func(token string) *httptest.ResponseRecorder { - r := httptest.NewRequest("GET", "/api/song", nil) - r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) - w := httptest.NewRecorder() - handler := JWTVerifier(Authenticator(ds)(http.HandlerFunc( - func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }, - ))) - handler.ServeHTTP(w, r) - return w - } - - It("accepts a current session token", func() { - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) - }) - - It("rejects a jellyfin-scoped token", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects a token with a stale epoch", func() { - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - usr.TokenEpoch = 3 - Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) - }) - - It("ignores a stray token for someone else when config auto-login resolves the user", func() { - conf.Server.DevAutoLoginUsername = usr.UserName - tokenStr, err := auth.CreateToken(&model.User{UserName: "someone-else"}) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) - }) - - It("rejects a stale-epoch token whose subject differs only in case from the resolved user", func() { - tokenStr, err := auth.CreateToken(&model.User{UserName: strings.ToUpper(usr.UserName), TokenEpoch: usr.TokenEpoch}) - Expect(err).ToNot(HaveOccurred()) - usr.TokenEpoch = 5 - Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("JWTRefresher", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - // TouchClaims reads this; left at zero every refreshed token is born expired. - conf.Server.SessionTimeout = time.Hour - auth.Init(&tests.MockDataStore{}) - }) - - serveWith := func(handler http.HandlerFunc) *httptest.ResponseRecorder { - usr := model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1} - tokenStr, err := auth.CreateToken(&usr) - Expect(err).ToNot(HaveOccurred()) - - r := httptest.NewRequest("GET", "/api/song", nil) - r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+tokenStr) - w := httptest.NewRecorder() - JWTVerifier(JWTRefresher(handler)).ServeHTTP(w, r) - return w - } - - It("writes a refreshed token when the handler writes a body", func() { - w := serveWith(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("ok")) - }) - Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty()) - }) - - It("writes a refreshed token when the handler writes no body", func() { - w := serveWith(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }) - Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty()) - }) - - It("picks up an epoch the handler reported", func() { - w := serveWith(func(w http.ResponseWriter, r *http.Request) { - request.SetTokenEpoch(r.Context(), 42) - w.WriteHeader(http.StatusOK) - }) - - claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader)) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Epoch).To(Equal(42)) - }) - - It("keeps the original epoch when the handler reports nothing", func() { - w := serveWith(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader)) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Epoch).To(Equal(1)) - }) - - It("propagates Flush to the underlying ResponseWriter", func() { - w := serveWith(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - w.(http.Flusher).Flush() - }) - Expect(w.Flushed).To(BeTrue()) - }) - - It("exposes the underlying ResponseWriter via Unwrap, for http.ResponseController lookups", func() { - var unwrapped http.ResponseWriter - w := serveWith(func(w http.ResponseWriter, _ *http.Request) { - u, ok := w.(interface{ Unwrap() http.ResponseWriter }) - Expect(ok).To(BeTrue()) - unwrapped = u.Unwrap() - w.WriteHeader(http.StatusOK) - }) - Expect(unwrapped).To(BeIdenticalTo(w)) - }) - }) }) diff --git a/server/backgrounds/backgrounds_suite_test.go b/server/backgrounds/backgrounds_suite_test.go deleted file mode 100644 index 5ad81907b..000000000 --- a/server/backgrounds/backgrounds_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package backgrounds - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestBackgrounds(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Backgrounds Suite") -} diff --git a/server/backgrounds/handler.go b/server/backgrounds/handler.go index dcaaa9c66..b00a51696 100644 --- a/server/backgrounds/handler.go +++ b/server/backgrounds/handler.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/random" "gopkg.in/yaml.v3" ) @@ -36,7 +35,7 @@ type Handler struct { func NewHandler() *Handler { h := &Handler{} - h.httpClient = cache.NewHTTPClient(httpclient.New(5*time.Second), imageListTTL) + h.httpClient = cache.NewHTTPClient(&http.Client{Timeout: 5 * time.Second}, imageListTTL) h.cache = cache.NewFileCache(imageCacheDir, imageCacheSize, imageCacheDir, imageCacheMaxItems, h.serveImage) go func() { _, _ = h.getImageList(log.NewContext(context.Background())) @@ -79,9 +78,9 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e if image == "" { return nil, errors.New("empty image name") } - c := httpclient.New(imageRequestTimeout) + c := http.Client{Timeout: imageRequestTimeout} req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil) - resp, err := c.Do(req) //nolint:bodyclose,gosec // On success the body is closed via the CachedStream wrapper + resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper if errors.Is(err, context.DeadlineExceeded) { defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline) return strings.NewReader(string(defaultImage)), nil @@ -90,7 +89,6 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e return nil, fmt.Errorf("could not get background image from hosting service: %w", err) } if resp.StatusCode != http.StatusOK { - _ = resp.Body.Close() return nil, fmt.Errorf("unexpected status code getting background image from hosting service: %d", resp.StatusCode) } log.Debug(ctx, "Got background image from hosting service", "image", image, "elapsed", time.Since(start)) diff --git a/server/backgrounds/handler_test.go b/server/backgrounds/handler_test.go deleted file mode 100644 index e8c77380f..000000000 --- a/server/backgrounds/handler_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package backgrounds - -import ( - "context" - "io" - "net/http" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type recordingBody struct { - io.Reader - closed *bool -} - -func (b recordingBody) Close() error { - *b.closed = true - return nil -} - -type stubTransport struct { - statusCode int - closed *bool -} - -func (t stubTransport) RoundTrip(*http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: t.statusCode, - Header: make(http.Header), - Body: recordingBody{Reader: strings.NewReader("image-bytes"), closed: t.closed}, - }, nil -} - -var _ = Describe("serveImage", func() { - var closed bool - - BeforeEach(func() { - closed = false - }) - - stubStatus := func(statusCode int) { - original := http.DefaultTransport - http.DefaultTransport = stubTransport{statusCode: statusCode, closed: &closed} - DeferCleanup(func() { http.DefaultTransport = original }) - } - - It("closes the response body when the hosting service returns an error", func() { - stubStatus(http.StatusNotFound) - - _, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) - - Expect(err).To(MatchError(ContainSubstring("unexpected status code"))) - Expect(closed).To(BeTrue(), "response body was left open") - }) - - It("hands the still-open body to the caller on success", func() { - stubStatus(http.StatusOK) - - reader, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) - - Expect(err).ToNot(HaveOccurred()) - Expect(closed).To(BeFalse(), "response body must stay open for the CachedStream wrapper") - body, _ := io.ReadAll(reader) - Expect(string(body)).To(Equal("image-bytes")) - }) -}) diff --git a/server/subsonic/e2e/doc.go b/server/e2e/doc.go similarity index 98% rename from server/subsonic/e2e/doc.go rename to server/e2e/doc.go index 9435d1f60..51ee6f047 100644 --- a/server/subsonic/e2e/doc.go +++ b/server/e2e/doc.go @@ -103,7 +103,7 @@ // // The e2e tests are included in the standard test suite and can be run with: // -// make test PKG=./server/subsonic/e2e # Run only e2e tests +// make test PKG=./server/e2e # Run only e2e tests // make test # Run all tests including e2e // make test-race # Run with race detector // diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go similarity index 66% rename from server/subsonic/e2e/e2e_suite_test.go rename to server/e2e/e2e_suite_test.go index fdfb562fd..7ce8de2e6 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -4,12 +4,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" + "strings" "testing" "testing/fstest" "time" @@ -20,6 +22,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" @@ -37,7 +40,6 @@ import ( "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/tests/harness" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -68,29 +70,18 @@ const ( mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id ) -// lyricFixture reads a public-domain lyric fixture (the same files the parser -// benchmarks use) so the e2e fixtures stay in sync with real-world content, -// including the word-level timing carried by the .elrc and .yaml variants. -func lyricFixture(name string) string { - // tests.Init chdirs to the project root, so reference fixtures from there. - data, err := os.ReadFile(filepath.Join("tests", "fixtures", "lyrics", name)) - Expect(err).ToNot(HaveOccurred(), "reading lyric fixture %q", name) - return string(data) -} - -// firstFixtureLine is the opening lyric line shared by every auld-lang-syne -// fixture; tests assert against it regardless of source format. -const firstFixtureLine = "Should auld acquaintance be forgot," - // Shared test state var ( ctx context.Context ds *tests.MockDataStore router *subsonic.Router - streamerSpy *harness.SpyStreamer - goldenDB *harness.DB + streamerSpy *spyStreamer lib model.Library + // Snapshot paths for fast DB restore + dbFilePath string + snapshotPath string + // Admin user used for most tests adminUser = model.User{ ID: "admin-1", @@ -108,6 +99,13 @@ var ( } ) +func createFS(files fstest.MapFS) storagetest.FakeFS { + fs := storagetest.FakeFS{} + fs.SetFiles(files) + storagetest.Register("fake", &fs) + return fs +} + // buildTestFS creates the full test filesystem matching the plan func buildTestFS() storagetest.FakeFS { abbeyRoad := template(_t{ @@ -130,15 +128,12 @@ func buildTestFS() storagetest.FakeFS { // Template for diverse-format transcode test tracks tcBase := _t{"albumartist": "Test Artist", "artist": "Test Artist", "album": "Transcode Formats", "year": 2024, "genre": "Test"} - // Template for lyrics e2e fixture tracks — isolated under Lyrics/ to keep other suite counts stable - lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"}) - - return harness.CreateFS(fstest.MapFS{ + return createFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), // "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID). "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", - _t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec, "bpm": 120})), + _t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec})), "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", _t{"musicbrainz_releasetrackid": mbidSomething, "musicbrainz_trackid": mbidSomethingRec})), // Rock / The Beatles / Help! (no MBIDs) @@ -182,31 +177,6 @@ func buildTestFS() storagetest.FakeFS { "bitrate": 4500, "samplerate": 48000, "bitdepth": 24, "channels": 6, "duration": int64(180), }), - // Lyrics fixtures (isolated under Lyrics/ to keep other suite counts stable). - // Content comes from tests/fixtures/lyrics (the same public-domain files the - // parser benchmarks use); the .elrc and .yaml variants carry word-level - // timing, which drives the v1 (line-level) vs v2 (enhanced/word-level) tests. - // - // Embedded — lyrics delivered via the "lyrics" tag, parsed at scan time. - // "Enhanced LRC" embeds ELRC (word-level) content; the title is kept generic - // since ELRC is still valid LRC. - "Lyrics/Embedded/01 - Embedded Enhanced LRC.mp3": lyricsAlbum(track(1, "Embedded Enhanced LRC", - _t{"lyrics": lyricFixture("auld-lang-syne.elrc")})), - "Lyrics/Embedded/02 - Embedded Plain.mp3": lyricsAlbum(track(2, "Embedded Plain", - _t{"lyrics": lyricFixture("auld-lang-syne.txt")})), - "Lyrics/Embedded/03 - Embedded TTML.mp3": lyricsAlbum(track(3, "Embedded TTML", - _t{"lyrics": lyricFixture("auld-lang-syne.ttml")})), - - // Sidecar — raw lyric text files read from the library FS at request time via fromExternalFile. - // The scanner skips non-audio extensions (.lrc, .srt, .yaml), so placing them as raw MapFile - // entries is safe: they are visible to the fake FS but invisible to the scanner. - "Lyrics/Sidecar/01 - Sidecar LRC.mp3": lyricsAlbum(track(1, "Sidecar LRC")), - "Lyrics/Sidecar/01 - Sidecar LRC.lrc": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.lrc")), ModTime: time.Now()}, - "Lyrics/Sidecar/02 - Sidecar SRT.mp3": lyricsAlbum(track(2, "Sidecar SRT")), - "Lyrics/Sidecar/02 - Sidecar SRT.srt": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.srt")), ModTime: time.Now()}, - "Lyrics/Sidecar/03 - Sidecar YAML.mp3": lyricsAlbum(track(3, "Sidecar YAML")), - "Lyrics/Sidecar/03 - Sidecar YAML.yaml": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.yaml")), ModTime: time.Now()}, - // _empty folder (directory with no audio) "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, }) @@ -222,10 +192,10 @@ func createUser(id, username, name string, isAdmin bool) model.User { IsAdmin: isAdmin, NewPassword: "password", } - Expect(ds.User().Put(ctx, &user)).To(Succeed()) - Expect(ds.User().SetUserLibraries(ctx, user.ID, []int{lib.ID})).To(Succeed()) + Expect(ds.User(ctx).Put(&user)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(user.ID, []int{lib.ID})).To(Succeed()) - loadedUser, err := ds.User().FindByUsername(ctx, user.UserName) + loadedUser, err := ds.User(ctx).FindByUsername(user.UserName) Expect(err).ToNot(HaveOccurred()) user.Libraries = loadedUser.Libraries return user @@ -311,14 +281,69 @@ func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic { // noopArtwork implements artwork.Artwork type noopArtwork struct{} -func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { - return nil, model.ErrNotFound +func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { + return nil, time.Time{}, model.ErrNotFound } -func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (*artwork.Image, error) { - return &artwork.Image{ReadCloser: io.NopCloser(io.LimitReader(nil, 0))}, nil +func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (io.ReadCloser, time.Time, error) { + return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil } +// spyStreamer captures the Request passed to NewStream for test assertions, +// then returns a minimal fake Stream so the handler completes without error. +type spyStreamer struct { + LastRequest stream.Request + LastMediaFile *model.MediaFile + SimulateError error // When set, NewStream returns this error + SimulateEmptyStream bool // When true, returns a 0-byte stream (simulates ffmpeg producing no output) +} + +func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { + s.LastRequest = req + s.LastMediaFile = mf + if s.SimulateError != nil { + return nil, s.SimulateError + } + format := req.Format + if format == "" || format == "raw" { + format = mf.Suffix + } + content := "fake audio data" + if s.SimulateEmptyStream { + content = "" + } + r := io.NopCloser(strings.NewReader(content)) + return stream.NewStream(mf, format, req.BitRate, r), nil +} + +// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. +type noopFFmpeg struct{} + +func (n noopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: transcode not supported") +} + +func (n noopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: extract image not supported") +} + +func (n noopFFmpeg) Probe(context.Context, []string) (string, error) { + return "", nil +} + +func (n noopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { + return nil, errors.New("noop ffmpeg: probe not supported") +} + +func (n noopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: convert animated image not supported") +} + +func (n noopFFmpeg) CmdPath() (string, error) { return "", nil } +func (n noopFFmpeg) IsAvailable() bool { return false } +func (n noopFFmpeg) IsProbeAvailable() bool { return true } +func (n noopFFmpeg) Version() string { return "noop" } + // noopArchiver implements core.Archiver type noopArchiver struct{} @@ -330,7 +355,7 @@ func (n noopArchiver) ZipArtist(context.Context, string, string, int, io.Writer) return model.ErrNotFound } -func (n noopArchiver) ZipShare(context.Context, *model.Share, io.Writer) error { +func (n noopArchiver) ZipShare(context.Context, string, io.Writer) error { return model.ErrNotFound } @@ -353,32 +378,80 @@ func (n noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFil return nil, nil } -func (n noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) { +func (n noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { return nil, nil } -func (n noopProvider) RefreshInfo(context.Context, model.Kind, string) error { - return nil +func (n noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} + +func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound } // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} + _ artwork.Artwork = noopArtwork{} + _ stream.MediaStreamer = &spyStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ ffmpeg.FFmpeg = noopFFmpeg{} ) var _ = BeforeSuite(func() { ctx = request.WithUser(GinkgoT().Context(), adminUser) + tmpDir := GinkgoT().TempDir() + dbFilePath = filepath.Join(tmpDir, "test-e2e.db") + snapshotPath = filepath.Join(tmpDir, "test-e2e.db.snapshot") + conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL" + db.Db().SetMaxOpenConns(1) + // Initial setup: schema, user, library, and full scan (runs once for the entire suite) conf.Server.MusicFolder = "fake:///music" - conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml" conf.Server.DevExternalScanner = false - buildTestFS() - goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) - lib = goldenDB.Library + db.Init(ctx) + + initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(initDS) + + adminUserWithPass := adminUser + adminUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed()) + + regularUserWithPass := regularUser + regularUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) + + lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} + Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) + + Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) + + loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) + Expect(err).ToNot(HaveOccurred()) + adminUser.Libraries = loadedUser.Libraries + + loadedRegular, err := initDS.User(ctx).FindByUsername(regularUser.UserName) + Expect(err).ToNot(HaveOccurred()) + regularUser.Libraries = loadedRegular.Libraries + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + buildTestFS() + s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err = s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + // Checkpoint WAL and snapshot the golden DB state + _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") + Expect(err).ToNot(HaveOccurred()) + data, err := os.ReadFile(dbFilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) }) // Close the database before the suite's TempDir cleanup runs. Required on @@ -404,16 +477,16 @@ func setupTestDB() { conf.Server.DevEnableMediaFileProbe = false // Restore DB to golden state (no scan needed) - goldenDB.Restore() + restoreDB() ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} auth.Init(ds) // Create the Subsonic Router with real DS, streamer spy, and real Decider - streamerSpy = &harness.SpyStreamer{} - decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) - s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + streamerSpy = &spyStreamer{} + decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, @@ -423,13 +496,49 @@ func setupTestDB() { noopProvider{}, s, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), + playlists.NewPlaylists(ds, core.NewImageUploadService()), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), - lyrics.NewLyrics(ds, nil), + lyrics.NewLyrics(nil), decider, nil, ) } + +// restoreDB restores all table data from the snapshot using ATTACH DATABASE. +// This is much faster than re-running the scanner for each test. +func restoreDB() { + sqlDB := db.Db() + + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) + Expect(err).ToNot(HaveOccurred()) + + rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") + Expect(err).ToNot(HaveOccurred()) + var tables []string + for rows.Next() { + var name string + Expect(rows.Scan(&name)).To(Succeed()) + tables = append(tables, name) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + rows.Close() + + for _, table := range tables { + // Table names come from sqlite_master, not user input, so concatenation is safe here + _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + _, err = sqlDB.Exec("DETACH DATABASE snapshot") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} diff --git a/server/subsonic/e2e/subsonic_album_lists_test.go b/server/e2e/subsonic_album_lists_test.go similarity index 90% rename from server/subsonic/e2e/subsonic_album_lists_test.go rename to server/e2e/subsonic_album_lists_test.go index e1be3f133..d41d17dbc 100644 --- a/server/subsonic/e2e/subsonic_album_lists_test.go +++ b/server/e2e/subsonic_album_lists_test.go @@ -19,7 +19,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(8)) + Expect(resp.AlbumList.Album).To(HaveLen(7)) }) It("type=alphabeticalByName sorts albums by name", func() { @@ -27,16 +27,15 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(8)) - // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Lyrics, Pop, Transcode Formats + Expect(albums).To(HaveLen(7)) + // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Pop, Transcode Formats Expect(albums[0].Title).To(Equal("Abbey Road")) Expect(albums[1].Title).To(Equal("COWBOY BEBOP")) Expect(albums[2].Title).To(Equal("Help!")) Expect(albums[3].Title).To(Equal("IV")) Expect(albums[4].Title).To(Equal("Kind of Blue")) - Expect(albums[5].Title).To(Equal("Lyrics")) - Expect(albums[6].Title).To(Equal("Pop")) - Expect(albums[7].Title).To(Equal("Transcode Formats")) + Expect(albums[5].Title).To(Equal("Pop")) + Expect(albums[6].Title).To(Equal("Transcode Formats")) }) It("type=alphabeticalByArtist sorts albums by artist name", func() { @@ -44,24 +43,23 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.AlbumList).ToNot(BeNil()) albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(8)) + Expect(albums).To(HaveLen(7)) // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" - // Non-compilations: Beatles (x2), Led Zeppelin, Lyric Tester, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ + // Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ Expect(albums[0].Artist).To(Equal("The Beatles")) Expect(albums[1].Artist).To(Equal("The Beatles")) Expect(albums[2].Artist).To(Equal("Led Zeppelin")) - Expect(albums[3].Artist).To(Equal("Lyric Tester")) - Expect(albums[4].Artist).To(Equal("Miles Davis")) - Expect(albums[5].Artist).To(Equal("Test Artist")) - Expect(albums[6].Artist).To(Equal("Various")) - Expect(albums[7].Artist).To(Equal("シートベルツ")) + Expect(albums[3].Artist).To(Equal("Miles Davis")) + Expect(albums[4].Artist).To(Equal("Test Artist")) + Expect(albums[5].Artist).To(Equal("Various")) + Expect(albums[6].Artist).To(Equal("シートベルツ")) }) It("type=random returns albums", func() { resp := doReq("getAlbumList", "type", "random") Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(8)) + Expect(resp.AlbumList.Album).To(HaveLen(7)) }) It("type=byGenre filters by genre parameter", func() { @@ -142,7 +140,7 @@ var _ = Describe("Album List Endpoints", func() { setupTestDB() // Star an album so the starred filter returns results - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -166,7 +164,7 @@ var _ = Describe("Album List Endpoints", func() { setupTestDB() // Rate an album so the highest filter returns results - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Kind of Blue"}, }) Expect(err).ToNot(HaveOccurred()) @@ -192,7 +190,7 @@ var _ = Describe("Album List Endpoints", func() { Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.AlbumList2).ToNot(BeNil()) albums := resp.AlbumList2.Album - Expect(albums).To(HaveLen(8)) + Expect(albums).To(HaveLen(7)) // Verify AlbumID3 format fields Expect(albums[0].Name).To(Equal("Abbey Road")) Expect(albums[0].Id).ToNot(BeEmpty()) @@ -203,7 +201,7 @@ var _ = Describe("Album List Endpoints", func() { resp := doReq("getAlbumList2", "type", "newest") Expect(resp.AlbumList2).ToNot(BeNil()) - Expect(resp.AlbumList2.Album).To(HaveLen(8)) + Expect(resp.AlbumList2.Album).To(HaveLen(7)) }) }) diff --git a/server/subsonic/e2e/subsonic_bookmarks_test.go b/server/e2e/subsonic_bookmarks_test.go similarity index 96% rename from server/subsonic/e2e/subsonic_bookmarks_test.go rename to server/e2e/subsonic_bookmarks_test.go index e2d659b9b..726b41743 100644 --- a/server/subsonic/e2e/subsonic_bookmarks_test.go +++ b/server/e2e/subsonic_bookmarks_test.go @@ -19,7 +19,7 @@ var _ = Describe("Bookmark and PlayQueue Endpoints", Ordered, func() { BeforeAll(func() { // Get a media file ID from the database to use for bookmarks - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1}) + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1}) Expect(err).ToNot(HaveOccurred()) Expect(mfs).ToNot(BeEmpty()) trackID = mfs[0].ID @@ -69,7 +69,7 @@ var _ = Describe("Bookmark and PlayQueue Endpoints", Ordered, func() { BeforeAll(func() { // Get multiple media file IDs from the database - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 3, Sort: "title"}) + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 3, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(len(mfs)).To(BeNumerically(">=", 2)) for _, mf := range mfs { diff --git a/server/subsonic/e2e/subsonic_browsing_test.go b/server/e2e/subsonic_browsing_test.go similarity index 87% rename from server/subsonic/e2e/subsonic_browsing_test.go rename to server/e2e/subsonic_browsing_test.go index 8aa93ee2e..55aeb8e9e 100644 --- a/server/subsonic/e2e/subsonic_browsing_test.go +++ b/server/e2e/subsonic_browsing_test.go @@ -13,15 +13,6 @@ var _ = Describe("Browsing Endpoints", func() { setupTestDB() }) - getBeatlesId := func() string { - artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - return artists[0].ID - } - Describe("getMusicFolders", func() { It("returns the configured music library", func() { resp := doReq("getMusicFolders") @@ -94,7 +85,12 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getMusicDirectory", func() { It("returns an artist directory with its albums as children", func() { - beatlesID := getBeatlesId() + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID resp := doReq("getMusicDirectory", "id", beatlesID) @@ -105,7 +101,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("returns an album directory with its tracks as children", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -129,7 +125,12 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getArtist", func() { It("returns artist with albums in ID3 format", func() { - beatlesID := getBeatlesId() + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID resp := doReq("getArtist", "id", beatlesID) @@ -140,7 +141,12 @@ var _ = Describe("Browsing Endpoints", func() { }) It("returns album names for the artist", func() { - beatlesID := getBeatlesId() + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID resp := doReq("getArtist", "id", beatlesID) @@ -159,7 +165,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("returns artist with a single album", func() { - artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"name": "Led Zeppelin"}, }) Expect(err).ToNot(HaveOccurred()) @@ -177,7 +183,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getAlbum", func() { It("returns album with its tracks", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -193,7 +199,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("includes correct track metadata", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -210,7 +216,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("returns album with correct artist and year", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Kind of Blue"}, }) Expect(err).ToNot(HaveOccurred()) @@ -236,7 +242,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getSong", func() { It("returns a song by its ID", func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "Come Together"}, }) Expect(err).ToNot(HaveOccurred()) @@ -260,7 +266,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("returns correct metadata for a jazz track", func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "So What"}, }) Expect(err).ToNot(HaveOccurred()) @@ -343,7 +349,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getAlbumInfo", func() { It("returns album info for a valid album", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -359,7 +365,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getAlbumInfo2", func() { It("returns album info for a valid album", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"album.name": "Abbey Road"}, }) Expect(err).ToNot(HaveOccurred()) @@ -375,7 +381,13 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getArtistInfo", func() { It("returns artist info for a valid artist", func() { - beatlesID := getBeatlesId() + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + resp := doReq("getArtistInfo", "id", beatlesID) Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -385,7 +397,13 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getArtistInfo2", func() { It("returns artist info2 for a valid artist", func() { - beatlesID := getBeatlesId() + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + beatlesID := artists[0].ID + resp := doReq("getArtistInfo2", "id", beatlesID) Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -408,33 +426,11 @@ var _ = Describe("Browsing Endpoints", func() { Expect(resp.TopSongs).ToNot(BeNil()) Expect(resp.TopSongs.Song).To(BeEmpty()) }) - - It("returns an error if no arguments are specified", func() { - resp := doReq("getTopSongs") - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.TopSongs).To(BeNil()) - }) - - It("returns a response only by id", func() { - beatlesID := getBeatlesId() - resp := doReq("getTopSongs", "id", beatlesID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TopSongs).ToNot(BeNil()) - }) - - It("returns a response when both id and name are included", func() { - beatlesID := getBeatlesId() - resp := doReq("getTopSongs", "id", beatlesID, "artist", "The Beatles") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TopSongs).ToNot(BeNil()) - }) }) Describe("getSimilarSongs", func() { It("returns a response for a valid song ID", func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "Come Together"}, }) Expect(err).ToNot(HaveOccurred()) @@ -451,7 +447,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getSimilarSongs2", func() { It("returns a response for a valid song ID", func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "Come Together"}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/subsonic_media_annotation_test.go b/server/e2e/subsonic_media_annotation_test.go similarity index 91% rename from server/subsonic/e2e/subsonic_media_annotation_test.go rename to server/e2e/subsonic_media_annotation_test.go index 4b90dd143..ec9b070de 100644 --- a/server/subsonic/e2e/subsonic_media_annotation_test.go +++ b/server/e2e/subsonic_media_annotation_test.go @@ -17,19 +17,19 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { BeforeAll(func() { // Look up a song from the scanned data - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) songID = songs[0].ID // Look up an album - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "name"}) + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) Expect(err).ToNot(HaveOccurred()) Expect(albums).ToNot(BeEmpty()) albumID = albums[0].ID // Look up an artist - artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "name"}) + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) Expect(err).ToNot(HaveOccurred()) Expect(artists).ToNot(BeEmpty()) artistID = artists[0].ID @@ -97,12 +97,12 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { var songID, albumID string BeforeAll(func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) songID = songs[0].ID - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "name"}) + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"}) Expect(err).ToNot(HaveOccurred()) Expect(albums).ToNot(BeEmpty()) albumID = albums[0].ID @@ -141,7 +141,7 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Describe("Scrobble", func() { It("submits a scrobble for a song", func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) @@ -155,7 +155,6 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) }) }) @@ -163,7 +162,7 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { var songID string BeforeAll(func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) songID = songs[0].ID diff --git a/server/subsonic/e2e/subsonic_media_retrieval_test.go b/server/e2e/subsonic_media_retrieval_test.go similarity index 94% rename from server/subsonic/e2e/subsonic_media_retrieval_test.go rename to server/e2e/subsonic_media_retrieval_test.go index b0ef4fd72..079b131ff 100644 --- a/server/subsonic/e2e/subsonic_media_retrieval_test.go +++ b/server/e2e/subsonic_media_retrieval_test.go @@ -21,7 +21,7 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { BeforeAll(func() { // All test tracks are mp3 at 320kbps - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) trackID = songs[0].ID @@ -106,15 +106,14 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { }) Describe("Download", func() { - var trackID, trackTitle string + var trackID string BeforeAll(func() { // All test tracks are mp3 at 320kbps - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) trackID = songs[0].ID - trackTitle = songs[0].Title }) It("returns error when id parameter is missing", func() { @@ -144,7 +143,6 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { Expect(w.Code).To(Equal(http.StatusOK)) Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="` + trackTitle + `.opus"`)) }) It("returns error when downloads are disabled", func() { diff --git a/server/subsonic/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go similarity index 86% rename from server/subsonic/e2e/subsonic_multilibrary_test.go rename to server/e2e/subsonic_multilibrary_test.go index 8158be1de..a837da124 100644 --- a/server/subsonic/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -6,6 +6,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -44,16 +45,16 @@ var _ = Describe("Multi-Library Support", Ordered, func() { // Create the second library in the DB (Put auto-assigns admin users) lib2 = model.Library{ID: 2, Name: "Classical Library", Path: "fake2:///classical"} - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) + Expect(ds.Library(ctx).Put(&lib2)).To(Succeed()) // Reload admin user to get both libraries in the Libraries field - loadedAdmin, err := ds.User().FindByUsername(ctx, adminUser.UserName) + loadedAdmin, err := ds.User(ctx).FindByUsername(adminUser.UserName) Expect(err).ToNot(HaveOccurred()) adminWithLibs = *loadedAdmin // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) - s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) @@ -65,10 +66,10 @@ var _ = Describe("Multi-Library Support", Ordered, func() { IsAdmin: false, NewPassword: "password", } - Expect(ds.User().Put(ctx, &userLib1Only)).To(Succeed()) - Expect(ds.User().SetUserLibraries(ctx, userLib1Only.ID, []int{lib.ID})).To(Succeed()) + Expect(ds.User(ctx).Put(&userLib1Only)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(userLib1Only.ID, []int{lib.ID})).To(Succeed()) - loadedUser, err := ds.User().FindByUsername(ctx, userLib1Only.UserName) + loadedUser, err := ds.User(ctx).FindByUsername(userLib1Only.UserName) Expect(err).ToNot(HaveOccurred()) userLib1Only.Libraries = loadedUser.Libraries }) @@ -141,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(8)) + Expect(resp.AlbumList.Album).To(HaveLen(7)) for _, a := range resp.AlbumList.Album { Expect(a.Title).ToNot(Equal("Symphony No. 9")) } @@ -181,7 +182,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { BeforeAll(func() { // Look up one song from each library - lib1Songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + lib1Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"media_file.library_id": lib.ID}, Max: 1, Sort: "title", }) @@ -189,7 +190,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { Expect(lib1Songs).ToNot(BeEmpty()) lib1SongID = lib1Songs[0].ID - lib2Songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + lib2Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"media_file.library_id": lib2.ID}, Max: 1, Sort: "title", }) @@ -224,31 +225,13 @@ var _ = Describe("Multi-Library Support", Ordered, func() { Expect(resp.Playlist.Entry).To(HaveLen(1)) Expect(resp.Playlist.Entry[0].Id).To(Equal(lib1SongID)) }) - - It("non-admin user cannot store a song from another library through createPlaylist", func() { - resp := doReqWithUser(userLib1Only, "createPlaylist", - "name", "Restricted Playlist", "songId", lib1SongID, "songId", lib2SongID) - Expect(resp.Status).To(Equal(responses.StatusOK)) - ownID := resp.Playlist.Id - - stored := doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID) - Expect(stored.Playlist.Entry).To(HaveLen(1), "the lib2 song must not be persisted") - Expect(stored.Playlist.Entry[0].Id).To(Equal(lib1SongID)) - - By("replacing the tracks of the same playlist") - resp = doReqWithUser(userLib1Only, "createPlaylist", "playlistId", ownID, "songId", lib2SongID) - Expect(resp.Status).To(Equal(responses.StatusOK)) - - stored = doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID) - Expect(stored.Playlist.Entry).To(BeEmpty()) - }) }) Describe("Cross-library shares", Ordered, func() { var lib2AlbumID string BeforeAll(func() { - lib2Albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ + lib2Albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"album.library_id": lib2.ID}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/subsonic_multiuser_test.go b/server/e2e/subsonic_multiuser_test.go similarity index 79% rename from server/subsonic/e2e/subsonic_multiuser_test.go rename to server/e2e/subsonic_multiuser_test.go index d8c5d3689..4a5c35a7e 100644 --- a/server/subsonic/e2e/subsonic_multiuser_test.go +++ b/server/e2e/subsonic_multiuser_test.go @@ -60,23 +60,15 @@ var _ = Describe("Multi-User Isolation", Ordered, func() { }) }) - Describe("getUsers authorization", func() { - It("succeeds for admin user", func() { - resp := doReqWithUser(adminUser, "getUsers") + Describe("getUsers for regular user", func() { + It("returns only the requesting user's info", func() { + resp := doReqWithUser(regularUser, "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(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)) + Expect(resp.Users.User[0].Username).To(Equal("regular")) + Expect(resp.Users.User[0].AdminRole).To(BeFalse()) }) }) }) diff --git a/server/subsonic/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go similarity index 90% rename from server/subsonic/e2e/subsonic_playlists_test.go rename to server/e2e/subsonic_playlists_test.go index 7a6d7df5d..466e68cf0 100644 --- a/server/subsonic/e2e/subsonic_playlists_test.go +++ b/server/e2e/subsonic_playlists_test.go @@ -19,7 +19,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { setupTestDB() // Look up song IDs from scanned data for playlist operations - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 6}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6}) Expect(err).ToNot(HaveOccurred()) Expect(len(songs)).To(BeNumerically(">=", 5)) for _, s := range songs { @@ -244,7 +244,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { BeforeAll(func() { setupTestDB() - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 6}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6}) Expect(err).ToNot(HaveOccurred()) Expect(len(songs)).To(BeNumerically(">=", 3)) for _, s := range songs { @@ -438,7 +438,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { setupTestDB() // Look up a song ID for mutation tests - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 1}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) songID = songs[0].ID @@ -450,7 +450,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { Public: false, Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}}, } - Expect(ds.Playlist().Put(ctx, smartPls)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(smartPls)).To(Succeed()) smartPlaylistID = smartPls.ID }) @@ -525,7 +525,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { BeforeAll(func() { setupTestDB() - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 1}) + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1}) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) songID = songs[0].ID @@ -543,7 +543,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}, } - Expect(ds.Playlist().Put(ctx, boolPls)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) boolPlaylistID = boolPls.ID // Create smart playlist with string "true" @@ -552,7 +552,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": "true"}}}, } - Expect(ds.Playlist().Put(ctx, stringPls)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) stringPlaylistID = stringPls.ID // Create smart playlist with string "true" in nested any group (exact issue #4826 scenario) @@ -565,7 +565,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { }, }}, } - Expect(ds.Playlist().Put(ctx, nestedPls)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(nestedPls)).To(Succeed()) nestedPlaylistID = nestedPls.ID }) @@ -607,7 +607,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{"genre": "true"}}}, } - Expect(ds.Playlist().Put(ctx, pls)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) resp := doReq("getPlaylist", "id", pls.ID) Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -620,7 +620,7 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, } - Expect(ds.Playlist().Put(ctx, pls)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) resp := doReq("getPlaylist", "id", pls.ID) Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -633,56 +633,18 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": true}}}, } - Expect(ds.Playlist().Put(ctx, boolPls)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) stringPls := &model.Playlist{ Name: "Genre Missing String2", OwnerID: adminUser.ID, Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, } - Expect(ds.Playlist().Put(ctx, stringPls)).To(Succeed()) + Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) boolResp := doReq("getPlaylist", "id", boolPls.ID) 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().Put(ctx, 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().Put(ctx, 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().Put(ctx, 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/subsonic/e2e/subsonic_radio_test.go b/server/e2e/subsonic_radio_test.go similarity index 51% rename from server/subsonic/e2e/subsonic_radio_test.go rename to server/e2e/subsonic_radio_test.go index d51cf6b14..ce64c31a1 100644 --- a/server/subsonic/e2e/subsonic_radio_test.go +++ b/server/e2e/subsonic_radio_test.go @@ -46,30 +46,6 @@ 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, @@ -88,35 +64,6 @@ 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) @@ -130,12 +77,4 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() { Expect(resp.InternetRadioStations).ToNot(BeNil()) Expect(resp.InternetRadioStations.Radios).To(BeEmpty()) }) - - It("deleteInternetRadioStation returns not found for a missing station", func() { - resp := doReq("deleteInternetRadioStation", "id", radioID) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorDataNotFound)) - }) }) diff --git a/server/subsonic/e2e/subsonic_scan_test.go b/server/e2e/subsonic_scan_test.go similarity index 100% rename from server/subsonic/e2e/subsonic_scan_test.go rename to server/e2e/subsonic_scan_test.go diff --git a/server/subsonic/e2e/subsonic_searching_test.go b/server/e2e/subsonic_searching_test.go similarity index 98% rename from server/subsonic/e2e/subsonic_searching_test.go rename to server/e2e/subsonic_searching_test.go index 00b60ad6f..e348bc6b9 100644 --- a/server/subsonic/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(7)) - Expect(resp.SearchResult3.Album).To(HaveLen(8)) - Expect(resp.SearchResult3.Song).To(HaveLen(20)) + Expect(resp.SearchResult3.Artist).To(HaveLen(6)) + Expect(resp.SearchResult3.Album).To(HaveLen(7)) + Expect(resp.SearchResult3.Song).To(HaveLen(14)) }) It("finds across all entity types simultaneously", func() { diff --git a/server/e2e/subsonic_sharing_test.go b/server/e2e/subsonic_sharing_test.go new file mode 100644 index 000000000..1a082ba0f --- /dev/null +++ b/server/e2e/subsonic_sharing_test.go @@ -0,0 +1,127 @@ +package e2e + +import ( + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Sharing Endpoints", Ordered, func() { + var shareID string + var albumID string + var songID string + + BeforeAll(func() { + conf.Server.EnableSharing = true + setupTestDB() + + 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 + + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + }) + + It("getShares returns empty initially", func() { + resp := doReq("getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(BeEmpty()) + }) + + It("createShare creates a share for an album", func() { + resp := doReq("createShare", "id", albumID, "description", "Check out this album") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(HaveLen(1)) + + share := resp.Shares.Share[0] + Expect(share.ID).ToNot(BeEmpty()) + Expect(share.Description).To(Equal("Check out this album")) + Expect(share.Username).To(Equal(adminUser.UserName)) + shareID = share.ID + }) + + It("getShares returns the created share", func() { + resp := doReq("getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(HaveLen(1)) + + share := resp.Shares.Share[0] + Expect(share.ID).To(Equal(shareID)) + Expect(share.Description).To(Equal("Check out this album")) + Expect(share.Username).To(Equal(adminUser.UserName)) + Expect(share.Entry).ToNot(BeEmpty()) + }) + + It("updateShare modifies the description", func() { + resp := doReq("updateShare", "id", shareID, "description", "Updated description") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Verify update + resp = doReq("getShares") + Expect(resp.Shares.Share).To(HaveLen(1)) + Expect(resp.Shares.Share[0].Description).To(Equal("Updated description")) + }) + + It("deleteShare removes it", func() { + resp := doReq("deleteShare", "id", shareID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("getShares returns empty after deletion", func() { + resp := doReq("getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(BeEmpty()) + }) + + It("createShare works with a song ID", func() { + resp := doReq("createShare", "id", songID, "description", "Great song") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(HaveLen(1)) + Expect(resp.Shares.Share[0].Description).To(Equal("Great song")) + Expect(resp.Shares.Share[0].Entry).To(HaveLen(1)) + }) + + It("createShare returns error when id parameter is missing", func() { + resp := doReq("createShare") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("updateShare returns error when id parameter is missing", func() { + resp := doReq("updateShare") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("deleteShare returns error when id parameter is missing", func() { + resp := doReq("deleteShare") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) +}) diff --git a/server/subsonic/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go similarity index 95% rename from server/subsonic/e2e/subsonic_sonic_similarity_test.go rename to server/e2e/subsonic_sonic_similarity_test.go index 80fa658b7..40161470b 100644 --- a/server/subsonic/e2e/subsonic_sonic_similarity_test.go +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -9,7 +9,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" @@ -22,7 +21,6 @@ import ( "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/tests/harness" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -34,22 +32,22 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { loader := &mockSonicPluginLoader{provider: provider} m := matcher.New(ds) sonicSvc := sonic.New(ds, loader, m) - decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) + decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) return subsonic.New( ds, noopArtwork{}, - &harness.SpyStreamer{}, + &spyStreamer{}, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, nil, // scanner events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), + playlists.NewPlaylists(ds, core.NewImageUploadService()), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), - lyrics.NewLyrics(ds, nil), + lyrics.NewLyrics(nil), decider, sonicSvc, ) @@ -100,14 +98,14 @@ var _ = Describe("Sonic Similarity Endpoints", func() { ) BeforeEach(func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "Come Together"}, }) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) comeTogether = songs[0] - songs, err = ds.MediaFile().GetAll(ctx, model.QueryOptions{ + songs, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"title": "Something"}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/subsonic_stream_test.go b/server/e2e/subsonic_stream_test.go similarity index 99% rename from server/subsonic/e2e/subsonic_stream_test.go rename to server/e2e/subsonic_stream_test.go index 81998760f..281524636 100644 --- a/server/subsonic/e2e/subsonic_stream_test.go +++ b/server/e2e/subsonic_stream_test.go @@ -21,7 +21,7 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { BeforeAll(func() { setupTestDB() - songs, err := ds.MediaFile().GetAll(ctx) + songs, err := ds.MediaFile(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) byTitle := map[string]string{} for _, s := range songs { diff --git a/server/subsonic/e2e/subsonic_system_test.go b/server/e2e/subsonic_system_test.go similarity index 100% rename from server/subsonic/e2e/subsonic_system_test.go rename to server/e2e/subsonic_system_test.go diff --git a/server/subsonic/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go similarity index 86% rename from server/subsonic/e2e/subsonic_transcode_test.go rename to server/e2e/subsonic_transcode_test.go index 313066938..ae3d6208c 100644 --- a/server/subsonic/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -127,7 +127,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { BeforeAll(func() { setupTestDB() - songs, err := ds.MediaFile().GetAll(ctx) + songs, err := ds.MediaFile(ctx).GetAll() Expect(err).ToNot(HaveOccurred()) byTitle := map[string]string{} for _, s := range songs { @@ -153,29 +153,18 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { // It makes a dummy request to register the player, then updates it via the repository. setPlayerMaxBitRate := func(maxBitRate int) { doReq("ping") - player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "") + player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") Expect(err).ToNot(HaveOccurred()) player.MaxBitRate = maxBitRate - Expect(ds.Player().Put(ctx, player)).To(Succeed()) - } - - setPlayerForcedFormat := func(format string) { - doReq("ping") - player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "") - Expect(err).ToNot(HaveOccurred()) - trc, err := ds.Transcoding().FindByFormat(ctx, format) - Expect(err).ToNot(HaveOccurred()) - player.TranscodingId = trc.ID - Expect(ds.Player().Put(ctx, player)).To(Succeed()) + Expect(ds.Player(ctx).Put(player)).To(Succeed()) } AfterEach(func() { // Reset player MaxBitRate to 0 after each test - player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "") + player, err := ds.Player(ctx).FindMatch(adminUser.ID, "test-client", "") if err == nil { player.MaxBitRate = 0 - player.TranscodingId = "" - _ = ds.Player().Put(ctx, player) + _ = ds.Player(ctx).Put(player) } }) @@ -407,34 +396,30 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate cap is enforced", func() { - It("forces transcode when source bitrate exceeds player MaxBitRate", func() { + Describe("player MaxBitRate cap is ignored", func() { + It("allows direct play even when source bitrate exceeds player MaxBitRate", func() { setPlayerMaxBitRate(320) // 320 kbps cap - // FLAC is 900kbps. Player cap (320) < source → direct play is - // rejected and the file is transcoded down. + // FLAC is 900kbps, player cap is 320, but getTranscodeDecision + // ignores server-side overrides — client profiles are used as-is 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(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))) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) }) - It("uses the player cap when it is more restrictive than the client limit", func() { + It("uses only client limit, not player MaxBitRate", func() { setPlayerMaxBitRate(192) // 192 kbps player cap - // Client caps at 320kbps (bitrateCapClient); player is more - // restrictive at 192 → player cap wins. + // Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192 + // but getTranscodeDecision ignores player cap → client limit (320kbps) applies 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()) - // Player cap (192kbps) applies → 192000 bps. - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) + // Only client limit (320kbps) applies → 320000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) }) }) @@ -490,73 +475,38 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate injected by getTranscodeDecision", func() { - It("injects the player cap as the transcode target when the client declares none", func() { + Describe("player MaxBitRate is ignored by getTranscodeDecision", func() { + It("does not inject maxAudioBitrate from player cap", func() { setPlayerMaxBitRate(320) - // opusTranscodeClient has no client bitrate limits. The player - // cap (320) is injected, so FLAC (900kbps) → opus is capped at 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 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 is the player cap (320kbps), not the opus format default. - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) + // Bitrate should be opus format default (128kbps), not player cap (320kbps) + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000))) }) - It("keeps the lower client maxTranscodingAudioBitrate over a higher player cap", func() { + It("uses only client maxTranscodingAudioBitrate, ignoring player cap", func() { setPlayerMaxBitRate(320) - // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps). - // Player cap (320) is higher → the lower client limit wins. + // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps) + // Player cap is 320, but getTranscodeDecision ignores it + // Only client maxTranscodingAudioBitrate=192 applies 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()) - // Client limit (192kbps) wins → 192000 bps. + // maxTranscodingAudioBitrate=192 → 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() { @@ -595,13 +545,13 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { Expect(token).ToNot(BeEmpty()) // Save original UpdatedAt and restore after test - mf, err := ds.MediaFile().Get(ctx, mp3TrackID) + mf, err := ds.MediaFile(ctx).Get(mp3TrackID) Expect(err).ToNot(HaveOccurred()) originalUpdatedAt := mf.UpdatedAt // Update the media file's UpdatedAt to simulate a change after token issuance mf.UpdatedAt = time.Now().Add(time.Hour) - Expect(ds.MediaFile().Put(ctx, mf)).To(Succeed()) + Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed()) // Attempt to stream with the now-stale token w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token) @@ -609,7 +559,7 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { // Restore original UpdatedAt mf.UpdatedAt = originalUpdatedAt - Expect(ds.MediaFile().Put(ctx, mf)).To(Succeed()) + Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed()) }) It("returns 500 when stream creation fails", func() { diff --git a/server/subsonic/e2e/subsonic_users_test.go b/server/e2e/subsonic_users_test.go similarity index 100% rename from server/subsonic/e2e/subsonic_users_test.go rename to server/e2e/subsonic_users_test.go diff --git a/server/events/sse.go b/server/events/sse.go index e4d6a05e5..565d8c016 100644 --- a/server/events/sse.go +++ b/server/events/sse.go @@ -34,7 +34,7 @@ type ( id uint64 event string data string - senderCtx context.Context //nolint:containedctx // queued message carries the sender ctx + senderCtx context.Context } messageChan chan message clientsChan chan client diff --git a/server/imghttp/headers.go b/server/imghttp/headers.go deleted file mode 100644 index 354b11793..000000000 --- a/server/imghttp/headers.go +++ /dev/null @@ -1,48 +0,0 @@ -// Package imghttp holds the HTTP caching contract shared by the subsonic, public, and jellyfin -// image handlers, so they apply identical headers without importing each other. -package imghttp - -import ( - "net/http" - - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/utils/req" -) - -// WriteImageHeaders applies the artwork caching contract and reports whether a 304 was written -// (the caller must then not write a body). requestedHash is the hash the client asserted, or "". -func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Image, requestedHash string) (wrote304 bool) { - h := w.Header() - // Placeholders are transient stand-ins for unresolved art: never cached, no validators. - if img.Placeholder { - h.Set("Cache-Control", "no-store") - return false - } - - // The ETag versions the served representation, so a re-encoding config change invalidates a - // revalidating client's cache; full-size originals are their own pixel hash. - etag := img.ETag - if etag == "" { - etag = img.Hash - } - // An empty validator would be shared by every such response, 304ing clients that echo it back. - if etag != "" { - h.Set("ETag", `"`+etag+`"`) - } - if !img.LastUpdated.IsZero() { - h.Set("Last-Modified", img.LastUpdated.UTC().Format(http.TimeFormat)) - } - // Immutable only when the client asked for the current pixel hash; anything else revalidates, - // so art that gets re-resolved is never pinned. - if requestedHash != "" && requestedHash == img.Hash { - h.Set("Cache-Control", "public, max-age=31536000, immutable") - } else { - h.Set("Cache-Control", "public, no-cache") - } - - if etag != "" && req.IfNoneMatch(r, etag) { - w.WriteHeader(http.StatusNotModified) - return true - } - return false -} diff --git a/server/imghttp/headers_test.go b/server/imghttp/headers_test.go deleted file mode 100644 index 2e8313faf..000000000 --- a/server/imghttp/headers_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package imghttp_test - -import ( - "io" - "net/http" - "net/http/httptest" - "strings" - "time" - - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/server/imghttp" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -const testHash = "0123456789abcdef" -const testRepTag = testHash + ".300.false.q75" - -var lastMod = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) - -func found() *artwork.Image { - return &artwork.Image{ - ReadCloser: io.NopCloser(strings.NewReader("IMG")), - Hash: testHash, - LastUpdated: lastMod, - } -} - -func placeholder() *artwork.Image { - return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("PH")), Placeholder: true} -} - -// A re-encoded response carries a representation ETag distinct from the pixel hash, so the -// validator versions with the encode settings. -func resized() *artwork.Image { - return &artwork.Image{ - ReadCloser: io.NopCloser(strings.NewReader("IMG")), - Hash: testHash, - ETag: testRepTag, - LastUpdated: lastMod, - } -} - -func unvalidated() *artwork.Image { - return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("IMG")), LastUpdated: lastMod} -} - -var _ = Describe("WriteImageHeaders", func() { - type testCase struct { - img *artwork.Image - requestedHash string - ifNoneMatch string - want304 bool - wantCache string - wantETag string - wantLastMod bool - } - - DescribeTable("applies the artwork caching contract", - func(c testCase) { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/img", nil) - if c.ifNoneMatch != "" { - r.Header.Set("If-None-Match", c.ifNoneMatch) - } - - Expect(imghttp.WriteImageHeaders(w, r, c.img, c.requestedHash)).To(Equal(c.want304)) - - h := w.Header() - Expect(h.Get("Cache-Control")).To(Equal(c.wantCache)) - Expect(h.Get("ETag")).To(Equal(c.wantETag)) - if c.img.Placeholder { - Expect(h.Get("ETag")).To(BeEmpty(), "placeholder must not set an ETag") - Expect(h.Get("Last-Modified")).To(BeEmpty(), "placeholder must not set Last-Modified") - } - Expect(h.Get("Last-Modified") != "").To(Equal(c.wantLastMod)) - if c.want304 { - Expect(w.Code).To(Equal(http.StatusNotModified)) - Expect(w.Body.Len()).To(BeZero(), "a 304 must have an empty body") - } - }, - Entry("placeholder is never cached and carries no validators", - testCase{img: placeholder(), wantCache: "no-store"}), - Entry("found with matching requested hash is immutable", - testCase{img: found(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("found with bare id revalidates via no-cache", - testCase{img: found(), wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("found with mismatched requested hash revalidates", - testCase{img: found(), requestedHash: "ffffffffffffffff", wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("resized keeps pixel-hash immutable but serves the representation ETag", - testCase{img: resized(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), - Entry("resized 304s on the representation ETag, not the pixel hash", - testCase{img: resized(), ifNoneMatch: `"` + testRepTag + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), - Entry("resized does not 304 on a stale pixel-hash validator (config changed)", - testCase{img: resized(), ifNoneMatch: `"` + testHash + `"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), - Entry("If-None-Match matching the hash yields 304", - testCase{img: found(), requestedHash: testHash, ifNoneMatch: `"` + testHash + `"`, want304: true, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("weak If-None-Match matches (weak comparison)", - testCase{img: found(), ifNoneMatch: `W/"` + testHash + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("If-None-Match with multiple values matches one", - testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef", W/"` + testHash + `", "cafecafecafecafe"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("If-None-Match star matches any current representation", - testCase{img: found(), ifNoneMatch: "*", want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("non-matching If-None-Match serves body", - testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("placeholder ignores If-None-Match and never 304s", - testCase{img: placeholder(), ifNoneMatch: "*", want304: false, wantCache: "no-store"}), - - // With no validator, an emitted ETag would be the same empty tag on every such response, - // and matching it would 304 changed bytes. - Entry("omits the ETag entirely when there is no validator", - testCase{img: unvalidated(), wantCache: "public, no-cache", wantLastMod: true}), - Entry("never 304s an empty validator echoed back by the client", - testCase{img: unvalidated(), ifNoneMatch: `""`, want304: false, - wantCache: "public, no-cache", wantLastMod: true}), - ) -}) diff --git a/server/imghttp/imghttp_suite_test.go b/server/imghttp/imghttp_suite_test.go deleted file mode 100644 index 7a747db5b..000000000 --- a/server/imghttp/imghttp_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package imghttp_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestImgHTTP(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "ImgHTTP Suite") -} diff --git a/server/initial_setup.go b/server/initial_setup.go index 462e22e54..7e974dc21 100644 --- a/server/initial_setup.go +++ b/server/initial_setup.go @@ -16,37 +16,34 @@ import ( func initialSetup(ds model.DataStore) { ctx := context.TODO() - err := ds.WithTx(func(tx model.DataStore) error { - if err := tx.Library().StoreMusicFolder(ctx); err != nil { + _ = ds.WithTx(func(tx model.DataStore) error { + if err := tx.Library(ctx).StoreMusicFolder(); err != nil { return err } - properties := tx.Property() - _, err := properties.Get(ctx, consts.InitialSetupFlagKey) + properties := tx.Property(ctx) + _, err := properties.Get(consts.InitialSetupFlagKey) if err == nil { return nil } log.Info("Running initial setup") if conf.Server.DevAutoCreateAdminPassword != "" { - if err = createInitialAdminUser(ctx, tx, conf.Server.DevAutoCreateAdminPassword); err != nil { + if err = createInitialAdminUser(tx, conf.Server.DevAutoCreateAdminPassword); err != nil { return err } } - err = properties.Put(ctx, consts.InitialSetupFlagKey, time.Now().String()) + err = properties.Put(consts.InitialSetupFlagKey, time.Now().String()) return err }, "initial setup") - if err != nil { - log.Fatal("Error running initial setup", err) - } } // If the Dev Admin user is not present, create it -func createInitialAdminUser(ctx context.Context, ds model.DataStore, initialPassword string) error { - users := ds.User() - c, err := users.CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"user_name": consts.DevInitialUserName}}) +func createInitialAdminUser(ds model.DataStore, initialPassword string) error { + users := ds.User(context.TODO()) + c, err := users.CountAll(model.QueryOptions{Filters: squirrel.Eq{"user_name": consts.DevInitialUserName}}) if err != nil { - return fmt.Errorf("could not access User table: %w", err) + panic(fmt.Sprintf("Could not access User table: %s", err)) } if c == 0 { newID := id.NewRandom() @@ -60,11 +57,12 @@ func createInitialAdminUser(ctx context.Context, ds model.DataStore, initialPass NewPassword: initialPassword, IsAdmin: true, } - if err := users.Put(ctx, &initialUser); err != nil { - return fmt.Errorf("could not create initial admin user: %w", err) + err := users.Put(&initialUser) + if err != nil { + log.Error("Could not create initial admin user", "user", initialUser, err) } } - return nil + return err } func checkFFmpegInstallation() { @@ -72,6 +70,10 @@ func checkFFmpegInstallation() { _, err := f.CmdPath() if err != nil { log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err) + if conf.Server.Scanner.Extractor == "ffmpeg" { + log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib") + conf.Server.Scanner.Extractor = "taglib" + } return } if !f.IsProbeAvailable() { diff --git a/server/initial_setup_test.go b/server/initial_setup_test.go index 0c85d9d0a..982046f78 100644 --- a/server/initial_setup_test.go +++ b/server/initial_setup_test.go @@ -2,7 +2,6 @@ package server import ( "context" - "errors" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -10,52 +9,28 @@ import ( . "github.com/onsi/gomega" ) -type failingPutUserRepo struct { - model.UserRepository - err error -} - -func (r *failingPutUserRepo) Put(context.Context, *model.User) error { return r.err } - -func dsWithFailingPut(err error) model.DataStore { - return &tests.MockDataStore{MockedUser: &failingPutUserRepo{UserRepository: tests.CreateMockUserRepo(), err: err}} -} - var _ = Describe("initial_setup", func() { var ds model.DataStore - var ctx context.Context BeforeEach(func() { ds = &tests.MockDataStore{} - ctx = GinkgoT().Context() }) Describe("createInitialAdminUser", func() { It("creates a new admin user with specified password if User table is empty", func() { - Expect(createInitialAdminUser(ctx, ds, "pass123")).To(BeNil()) - ur := ds.User() - admin, err := ur.FindByUsername(ctx, "admin") + Expect(createInitialAdminUser(ds, "pass123")).To(BeNil()) + ur := ds.User(context.TODO()) + admin, err := ur.FindByUsername("admin") Expect(err).To(BeNil()) Expect(admin.Password).To(Equal("pass123")) }) It("does not create a new admin user if User table is not empty", func() { - Expect(createInitialAdminUser(ctx, ds, "first")).To(BeNil()) - ur := ds.User() - Expect(ur.CountAll(ctx)).To(Equal(int64(1))) - Expect(createInitialAdminUser(ctx, ds, "second")).To(BeNil()) - Expect(ur.CountAll(ctx)).To(Equal(int64(1))) - }) - - It("returns the error when the user cannot be stored", func() { - boom := errors.New("db is down") - Expect(createInitialAdminUser(ctx, dsWithFailingPut(boom), "pass123")).To(MatchError(boom)) - }) - - It("returns the error when the user table cannot be read", func() { - boom := errors.New("db is down") - ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: boom}} - Expect(createInitialAdminUser(ctx, ds, "pass123")).To(MatchError(boom)) + Expect(createInitialAdminUser(ds, "first")).To(BeNil()) + ur := ds.User(context.TODO()) + Expect(ur.CountAll()).To(Equal(int64(1))) + Expect(createInitialAdminUser(ds, "second")).To(BeNil()) + Expect(ur.CountAll()).To(Equal(int64(1))) }) }) }) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md deleted file mode 100644 index 9315ac4f9..000000000 --- a/server/jellyfin/README.md +++ /dev/null @@ -1,421 +0,0 @@ -# Jellyfin API - -This package implements a subset of the [Jellyfin](https://jellyfin.org/) REST API on top of -Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets -Jellyfin-compatible clients (e.g. [Finamp](https://github.com/jmshrv/finamp), -[jftui](https://github.com/Aanok/jftui)) browse and stream a Navidrome library without -requiring a real Jellyfin server. - -It is **not** a full Jellyfin server implementation: only the endpoints needed to browse a music -library, stream audio, manage favorites/ratings for songs, albums, artists, and playlists, report -playback, and manage playlists are implemented. Video, live TV, plugins, and Jellyfin's -admin/dashboard APIs are out of scope. - -## Enabling - -The Jellyfin API is disabled by default. Enable it via `navidrome.toml`: - -```toml -[Jellyfin] -Enabled = true -# Optional: override the server name reported to clients (defaults to "Navidrome ") -ServerName = "My Music Server" -# Optional: usernames to show in the client login user-picker (default: none). See "Public user list". -ExposedPublicUsers = "alice, bob" -# Optional: answer LAN auto-discovery broadcasts on UDP 7359 (default: false). See "Auto discovery". -AutoDiscovery = true -# Optional: let users sign in new devices with a 6-digit code (default: true). See "Quick Connect". -QuickConnect = false -# Optional: max collection responses streaming at once (default: half the DB connection pool, -# min 2). Each streaming response holds a DB connection for its whole duration; excess requests -# queue rather than fail. -MaxConcurrentStreams = 4 -``` - -or via environment variables: - -```bash -ND_JELLYFIN_ENABLED=true -ND_JELLYFIN_SERVERNAME="My Music Server" -ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" -ND_JELLYFIN_AUTODISCOVERY=true -ND_JELLYFIN_QUICKCONNECT=false -ND_JELLYFIN_MAXCONCURRENTSTREAMS=4 -``` - -Once enabled, the API is mounted at: - -``` -http://:/jellyfin -``` - -All the paths below are relative to that base URL (e.g. `System/Info/Public` means -`http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**, -since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths. - -## Auto discovery - -With `AutoDiscovery = true`, Navidrome answers the Jellyfin LAN discovery broadcast -(`who is JellyfinServer?` on UDP port 7359), so clients list the server without a typed URL. -It is off by default because a real Jellyfin server on the same host owns that port. If the port -is taken, Navidrome logs a warning and keeps running without discovery. - -The advertised address is `BaseURL` when it includes a host. Otherwise it is the bind `Address` -when that is a specific IP, or else the local IP that faces the requesting client, plus `Port`. With a -unix socket `Address` there is no port to advertise, so discovery only starts when `BaseURL` has a host. - -Discovery answers on all IPv4 interfaces, but the advertised address follows `BaseURL`, `Address` and -`Port`. If `Address` is a loopback or a single interface IP and `BaseURL` has no host, clients on other -networks get an address they cannot reach. Set `BaseURL` to the address clients should use. - -Docker: use host networking (`network_mode: host` / `--network host`). On Linux, bridge mode does not -deliver broadcasts to the container, even with `-p 7359:7359/udp`, so clients never find the server. -With host networking the server sees the host's IP, so `BaseURL` is not needed for discovery. If host -networking is not an option, leave discovery off. Keep UDP 7359 on the LAN: never forward it from the internet. - -## Authentication - -Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome -username/password, and get back an `AccessToken` (a Navidrome JWT). That token is then sent on -every subsequent request as the `X-Emby-Token` header (or embedded in the -`X-Emby-Authorization`/`Authorization` header's `Token="..."` field, or as an `api_key`/`ApiKey` -query param — all forms are accepted, matching what different clients do). - -`POST /Users/AuthenticateByName` is rate-limited per IP with the same limiter as the native -`/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force -surface. - -Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch. - -### Quick Connect - -Quick Connect signs a new device in without typing a password. The client shows a 6-digit code, -a signed-in user approves it, and the client gets its `AccessToken`. It is on by default -(`Jellyfin.QuickConnect`); when off, `GET QuickConnect/Enabled` returns `false` and every other -Quick Connect call returns 401. - -1. `POST QuickConnect/Initiate` (public; needs `Client`, `Device`, `DeviceId` and `Version` in the - auth header) returns the `Code` and a `Secret`. -2. The user approves the code, either in the Navidrome web UI (user menu → **Quick Connect**, which - shows the app and device before approving) or from a signed-in Jellyfin client with - `POST QuickConnect/Authorize?Code=`. Admins may pass `UserId` to approve for another user. -3. The client polls `GET QuickConnect/Connect?Secret=` until `Authenticated` is `true`. -4. `POST Users/AuthenticateWithQuickConnect` with `{"Secret": "..."}` returns the same result as - `AuthenticateByName`. - -Pending codes live in memory and expire after 10 minutes (a server restart drops them). Unlike -Jellyfin, a secret signs in only once. `Initiate`, `AuthenticateWithQuickConnect` and code approval -(`Authorize` and the web UI) are rate-limited per IP like the login; `Connect` is not, since some -clients poll it every second. - -### Public user list (login picker) - -`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the -password) instead of a blank username field. It's **unauthenticated**, so by default it exposes -**no** users. Set `Jellyfin.ExposedPublicUsers` to a comma-separated list of usernames to advertise: - -```toml -[Jellyfin] -ExposedPublicUsers = "alice, bob" -``` - -Only the named users are listed (never the full user table), resolved live per request; a configured -name that doesn't exist is skipped and logged at `Warn`. Each entry is a minimal DTO (`Name`, `Id`) -with no `Policy`/`Configuration`, so admin status isn't leaked to unauthenticated callers, and no -avatar (`PrimaryImageTag` omitted — Navidrome has no per-user profile images). - -## Players and sessions - -Every authenticated request registers (or refreshes) the calling device as a Navidrome player, -mirroring Subsonic's `getPlayer` — so a Jellyfin client shows up in the players list (and scrobbling -has a player) as soon as it makes any authenticated call, not only when it reports playback. The -player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the player name is -`Client [Device]`. Those field values are URL-decoded, since some clients percent-encode them -(Jellify sends `Device="Pixel%208%20Pro"`, Finamp sends it raw). A request that carries no -client/device info (e.g. the `GET socket` handshake, which authenticates via `?api_key=` only) is -skipped, so it doesn't create a nameless player. - -## Multi-library behavior - -Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each -Navidrome library the current user can access is exposed as its own top-level Jellyfin -"CollectionFolder" view (`GET /UserViews`), instead of merging every library into a single view. -Browsing (`/Items`), artists, and the "Latest" list are all scoped to the libraries the -authenticated user has access to. Fetching an item the user cannot access returns `404`, never -`403`, so ids can't be used as an existence oracle. An inaccessible library id sent as `ParentId` -is not a `404`: it is simply not treated as a library, so none of that library's content is -returned. - -### Browsing filters - -`GET /Items` accepts the filter params clients use to build screens: `ParentId` (a library view id -for scoping, an artist id when browsing into an artist's albums, or an album id when browsing into -an album's tracks); `AlbumArtistIds`/`ArtistIds`/`contributingArtistIds` (an artist's albums or -tracks — Finamp's artist screen sends these *alongside* `ParentId=`); `AlbumIds` (an -album's tracks — Feishin fetches them this way instead of `ParentId`); `GenreIds` (a -genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` -and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); -`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`; -`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone -`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as -in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome -equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties; -unrecognized keys are skipped, and `Random` always sorts alone); -`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId` -returns direct children only (no tracks — no track is a library's direct child). - -## Implemented endpoints - -| Area | Endpoints | -|---|---| -| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated) | -| Quick Connect | `GET QuickConnect/Enabled`, `POST QuickConnect/Initiate`, `GET QuickConnect/Connect`, `POST QuickConnect/Authorize` (authenticated), `POST Users/AuthenticateWithQuickConnect` | -| Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | -| Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | -| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Items/Latest`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | -| Artists / genres / labels | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres`, `GET Studios`, `GET Items/Filters` | -| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Albums/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` | -| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) | -| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` | -| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) | -| Lyrics | `GET Audio/{itemId}/Lyrics` | -| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` | -| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items` (`POST` honors `position`), `POST Playlists/{playlistId}/Items/{entryId}/Move/{newIndex}`, `GET Playlists/{playlistId}/Users[/{userId}]` | -| Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) | -| AudioMuse-AI (see below) | `GET AudioMuseAI/info`, `GET AudioMuseAI/health`, `GET AudioMuseAI/similar_tracks`, `GET AudioMuseAI/find_path` | - -Any other path returns a `404` with a `{}` JSON body, and is logged server-side at `Debug` level -as `Jellyfin API: unhandled route` (method + path). If a client you're testing needs an endpoint -that isn't in the table above, check the server logs for these lines to see exactly what it's -requesting. - -## Playlist management - -Playlists are the main writable surface of this API: - -- **Container expansion.** When creating (`POST Playlists`), adding to (`POST Playlists/{id}/Items`) - or replacing (`POST Playlists/{id}`) a playlist, the `Ids` may contain **containers** — album, - artist or playlist ids — not just song ids. Each is expanded into its tracks (in order) before - the write, matching how Jellyfin clients populate these lists. A bare song id passes through. -- **Id list encoding.** `POST`/`DELETE Playlists/{id}/Items` accept the id list both ways clients - spell it: repeated params (`ids=X&ids=Y`, how Jellify's `@jellyfin/sdk` serializes arrays) and a - single comma-separated value (`ids=X,Y`, Finamp). Reading only the first value would add just one - track of an expanded album. -- **Update** (`POST Playlists/{id}`): with `Ids` present, the track list is **replaced** (Finamp - uses this for reordering) — an explicit empty `Ids` (`[]`) **clears** the playlist, while an - omitted `Ids` leaves the tracks untouched and only updates `Name`/`IsPublic`. `IsPublic` maps to - Navidrome's `Public` flag, surfaced to clients as `OpenAccess` on `GET Playlists/{id}`. -- **Cover art**: `POST Items/{id}/Images/Primary` uploads a playlist cover (raw or base64 body, - JPEG/PNG/WebP/GIF detected by magic number, extension from `Content-Type`); `DELETE` removes it. - Only playlists are writable through this API — album/artist covers come from tag/sidecar scanning, - so a non-playlist id returns `501`. Uploads honor the same gates as the native endpoint: they're - bounded by `MaxImageUploadSize` and require `EnableArtworkUpload` for non-admins. -- **`PlaylistItemId`**: `GET Playlists/{id}/Items` tags each entry with `PlaylistItemId` (the - playlist-track row id, distinct from the song id) so a client can echo it back via - `DELETE Playlists/{id}/Items?EntryIds=...` to remove one occurrence of a song that appears more - than once in the same playlist. - -Ownership is enforced by `core/playlists`: a non-owner editing/deleting a playlist gets `403` if -it is visible to them (public) or `404` if it is not (private) — the API never reveals that -someone else's private playlist exists. - -## Images - -The `GET Items/{itemId}/Images/{type}` route is intentionally **public** (artwork isn't sensitive, -matching Jellyfin's lenient image handling), so it carries no authenticated user. Artwork is -therefore resolved under an **elevated admin context** — the same approach `core/artwork`'s cache -warmer uses — so user-scoped items like private playlists still resolve their cover instead of -falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to -their Navidrome `ArtworkID`. - -## Item ids are GUIDs - -Jellyfin item ids are GUIDs, serialized as 32 lowercase hex chars with no dashes -(`Guid.ToString("N")`). Navidrome ids are canonical 22-char base62 encodings of a 128-bit value, -so `dto.EncodeID`/`dto.DecodeID` map between the two via `model/id` — losslessly except for the -~2⁻⁹⁶ chance an id's 128-bit value falls in the reserved space below (leading 12 bytes all zero). - -Three emitted ids aren't 128-bit values: integer library ids, the synthetic playlists folder, and -`PlaylistItemId` (a playlist *entry position* — `playlist_tracks.id` is an `integer` column). -They use a reserved GUID space — 12 zero bytes, a non-zero kind tag, a 24-bit payload — so -library `1` is `00000000000000000000000001000001`. The tag is never zero, because Jellyfin -serializes the all-zero GUID as `null`. - -`DecodeID` accepts dashed and uppercase GUIDs (Jellyfin's `Guid.Parse` does) and returns -`ok=false` for anything malformed — including "" — which handlers surface as a 404. - -The wire format must stay GUID-shaped for this reason: Finamp's saved-queue persistence bit-packs -each item id into exactly 16 bytes (`packIds()` in `lib/models/finamp_models.dart`), so a 32-hex -GUID round-trips exactly, whereas a longer id would be silently truncated. - -## Streaming and transcoding - -The stream endpoints reuse the same transcode-decision pipeline as the Subsonic `/stream` endpoint: - -- **`GET Audio/{id}/stream[.{container}]` / `universal`** — the target format comes from the - `.{container}` path suffix, the `container` param, or (when neither is present) `audioCodec`. - `audioBitRate`/`maxStreamingBitrate` are bits/sec, per Jellyfin convention. `static=true` - forces direct play (raw), never a transcode. -- **`GET Items/{id}/File` / `Download`** — always the original file bytes, matching real Jellyfin. - Finamp plays through `File` when its transcoding setting is off, so an undecodable format (e.g. - DSF) can't be rescued server-side on this path. -- **`GET Audio/{id}/main.m3u8`** — the endpoint Finamp plays through when its transcoding setting - is on. Implemented as a single-segment HLS VOD playlist whose one segment is the progressive - transcode endpoint above, so the whole pipeline (decision, cache, forced transcoding) is reused. - Segment codec honors `audioCodec` but is limited to what HLS packed-audio can carry (`aac`, - `mp3`); anything else falls back to `aac`. Seeking re-reads from the start, like Subsonic - transcoded streams. -- **Server-forced transcoding.** A format/bitrate configured on the registered player (Settings → - Players) is applied to `stream`, `universal` and `main.m3u8` — same override semantics as - Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are - advertised and served but packed-audio players won't decode them. - -## Lyrics - -`GET Audio/{id}/Lyrics` serves the main lyric track as a `LyricDto` (`Start` in 100ns ticks, -word-level `Cues` when present), resolved through the full `core/lyrics` pipeline (embedded, `.lrc` -sidecars, plugins per `LyricsPriority`). No lyrics returns `404`, never an empty `200`. - -Results, misses included, are cached for 5 minutes: Jellify fetches lyrics for every played track and -Feishin on every song change, so lyric-less tracks are the hot path. Concurrent misses on the same -track share one pipeline run. That run is detached from the request, so a cancelled request doesn't -fail it for other waiters, and its context has a one-minute deadline. - -Finamp opens its lyrics view only when the track has a `Lyric` `MediaStream` (`HasLyrics` is just a -list badge). Browse lists set both from embedded lyrics only. `PlaybackInfo` runs the full pipeline -per track, so sidecar and plugin lyrics show up there. Feishin also requires server version ≥ 10.9 -(we advertise 12.1.0). - -## AudioMuse-AI compatible endpoints - -Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin) -— e.g. [Symfonium](https://symfonium.app/) can use these endpoints for sonic mixes when -connected as a Jellyfin client. -Backed natively by Navidrome's `core/sonic` engine (the `SonicSimilarity` plugin capability) — no -external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a `SonicSimilarity` -plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension. - -- `GET /AudioMuseAI/info` — returns `{"Version": , "AvailableEndpoints": [...]}` (200). - `AvailableEndpoints` lists the endpoints below only when a provider is loaded; otherwise it is empty. -- `GET /AudioMuseAI/health` — liveness probe: 200 with an empty body when a provider is loaded, else 404. -- `GET /AudioMuseAI/similar_tracks?item_id=&n=10&eliminate_duplicates=true` — 404 when no provider is - loaded; otherwise a JSON array of `{author, distance, item_id, title}` (200; `[]` when there is no match - or no `item_id`). `eliminate_duplicates` (default true) limits results to one track per artist. -- `GET /AudioMuseAI/find_path?start_song_id=&end_song_id=&max_steps=25` — 404 when no provider is - loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": }` (200), or 400 - with `start_song_id and end_song_id are required.` when either id is missing. - -`item_id`/`start_song_id`/`end_song_id` are the GUID-form ids Navidrome hands Jellyfin clients. -`tempo` comes from the track's BPM when known; the richer AudioMuse per-track features -(`energy`, `key`, `mood_vector`, `scale`, `other_features`) are not provided. In multi-library -setups, `find_path`'s `path` and `total_distance` only reflect hops through tracks in libraries -the caller can access, since hops through inaccessible libraries are filtered out of the result. - -## curl walkthrough - -This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the -library hierarchy, fetch playback info, stream, favorite, report playback, and manage a playlist. - -```bash -BASE=http://localhost:4533/jellyfin - -# 1. Handshake (no auth required) -curl -s "$BASE/System/Info/Public" | jq . - -# 2. Login - capture the AccessToken -TOKEN=$(curl -s -X POST "$BASE/Users/AuthenticateByName" \ - -H 'Content-Type: application/json' \ - -d '{"Username":"admin","Pw":"password"}' | jq -r .AccessToken) - -AUTH=(-H "X-Emby-Token: $TOKEN") - -# 3. List the user's views (one per accessible library) -curl -s "${AUTH[@]}" "$BASE/UserViews" | jq . - -# 4. Browse artists -curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist" | jq . -ARTIST_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist&Limit=1" | jq -r '.Items[0].Id') - -# 5. Drill into that artist's albums (ParentId with no IncludeItemTypes defaults to MusicAlbum) -ALBUM_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?ParentId=$ARTIST_ID" | jq -r '.Items[0].Id') - -# 6. List the album's songs -USER_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/Me" | jq -r .Id) -SONG_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/$USER_ID/Items?ParentId=$ALBUM_ID&IncludeItemTypes=Audio" \ - | jq -r '.Items[0].Id') - -# 7. Ask for playback info, then stream the song -curl -s -X POST "${AUTH[@]}" "$BASE/Items/$SONG_ID/PlaybackInfo" | jq . -curl -s "${AUTH[@]}" "$BASE/Audio/$SONG_ID/stream" -o /tmp/song.audio - -# 8. Favorite the song -curl -s -X POST "${AUTH[@]}" "$BASE/Users/$USER_ID/FavoriteItems/$SONG_ID" | jq . - -# 9. Report playback start/stop (also drives scrobbling) -curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ - -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":0}" "$BASE/Sessions/Playing" -curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ - -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":1200000000}" "$BASE/Sessions/Playing/Stopped" - -# 10. Create a playlist from a whole album (the album id is expanded to its tracks) -PLAYLIST_ID=$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ - -d "{\"Name\":\"My Playlist\",\"Ids\":[\"$ALBUM_ID\"]}" "$BASE/Playlists" | jq -r .Id) - -# 11. Make it public, then remove one entry -curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ - -d '{"IsPublic":true}' "$BASE/Playlists/$PLAYLIST_ID" -ENTRY_ID=$(curl -s "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items" | jq -r '.Items[0].PlaylistItemId') -curl -s -X DELETE "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items?EntryIds=$ENTRY_ID" - -# 12. Delete the playlist -curl -s -X DELETE "${AUTH[@]}" "$BASE/Items/$PLAYLIST_ID" -``` - -## Testing - -Handler-level unit tests live alongside each file (`*_test.go`). A full end-to-end suite in -[`e2e/`](e2e) exercises every endpoint through the real router against a real SQLite database and -real repositories (only artwork, streaming, ffmpeg, external metadata agents and sonic similarity -are stubbed), with per-`Describe` snapshot isolation — mirroring the Subsonic `server/subsonic/e2e` -suite. Run it with: - -```bash -make test PKG=./server/jellyfin/... -``` - -## Known limitations - -- **Genre lists ignore `ParentId`.** `GET Genres`/`MusicGenres` and - `Items?IncludeItemTypes=MusicGenre` list the genres of every library the user can access, never - only the `ParentId` library. `Items?IncludeItemTypes=MusicGenre` also ignores `SearchTerm`. - `GET Items/Filters` is the exception: its genres follow `ParentId`. -- **Search skips some filters.** With `SearchTerm`, `Filters=IsFavorite`, `IsPlayed`, `IsUnplayed` - and `isFavorite`/`isPlayed` are skipped, so the response holds every search match. The full-text - search's first phase has no annotation join to filter on. Artist searches also skip the role (album - artist vs. artist) and `GenreIds`. Playlist lists ignore `SearchTerm`. -- **Search pages are capped at 2,000 items.** A larger `Limit` is lowered to 2,000. Jellyfin has no - cap. -- **One-character searches return nothing.** The shared search layer ignores terms shorter than two - characters, so album, artist and song searches for `a` come back empty. Real Jellyfin has no - minimum. -- **Playlist entry ids are positions, not song ids.** `PlaylistItemId` encodes the entry's position - in the playlist, so ids change when entries are inserted or removed; re-read the list after an - edit. Real Jellyfin uses the song id. Clients that echo `PlaylistItemId` back (Finamp) work; - clients that send the song id as `EntryIds` or to `Move` (Jellify) get `404`. -- **Rating uses the `rating` param.** `POST Users/{userId}/Items/{itemId}/Rating` reads a `rating` - (0-10) and stores it as 0-5 stars. Jellyfin's `likes` boolean is not read, so a client that sends - only `likes` clears the rating. -- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed), `Search/Hints` and the non-legacy - `UserItems/{itemId}/Rating` are not implemented. Play counts only change through playback - reporting. -- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are - accepted but never returned. -- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork - pipeline computes and stores. Until that happens for an item, the field is omitted: a fake value - would pin the wrong placeholder in clients that key their image cache on it. -- **The WebSocket only keeps alive; it pushes no events.** `GET socket` sends `ForceKeepAlive` and - answers `KeepAlive` pings, so real-time clients (Finamp) keep a working session instead of - reconnecting in a loop. It never pushes session, playstate or library-change events. -- **List lyric badges only see embedded lyrics.** Tracks with only sidecar or plugin lyrics show no - `HasLyrics` badge in lists, because those sources can't be known at list time without per-row I/O. - `PlaybackInfo` and `Audio/{id}/Lyrics` still find them (see "Lyrics"). diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go deleted file mode 100644 index a589798be..000000000 --- a/server/jellyfin/annotations.go +++ /dev/null @@ -1,129 +0,0 @@ -package jellyfin - -import ( - "errors" - "math" - "net/http" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" -) - -// resolveAnnotated finds which annotated repo owns id, returning the resource name used in -// refreshResource events. Albums and songs 404 when the user can't access their library; artists -// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time -// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has -// already been written, so callers must return without writing the annotation. -func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) { - ctx := r.Context() - entity, err := model.GetEntityByID(ctx, api.ds, id) - if err != nil && !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, "" - } - u, _ := request.UserFrom(ctx) - switch e := entity.(type) { - case *model.Album: - if u.HasLibraryAccess(e.LibraryID) { - return api.ds.Album(), "album" - } - case *model.Artist: - return api.ds.Artist(), "artist" - case *model.MediaFile: - if u.HasLibraryAccess(e.LibraryID) { - return api.ds.MediaFile(), "song" - } - case *model.Playlist: - return api.ds.Playlist(), "playlist" - } - // Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent. - http.Error(w, "Not Found", http.StatusNotFound) - return nil, "" -} - -// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify -// fetches this per item to render played/favourite indicators; resolveItemByID enforces the -// library-access gate. -func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - item, ok := api.resolveItemByID(r.Context(), id, nil) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - data := item.UserData - if data == nil { - // Items without annotations still return a valid empty UserData. - data = dto.UserData(model.Annotations{}, id) - } - api.ok(w, r, data) -} - -func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - repo, resource := api.resolveAnnotated(w, r, id) - if repo == nil { - return - } - if err := repo.SetStar(r.Context(), starred, id); err != nil { - api.internalError(w, r, err) - return - } - api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) - encodedID := dto.EncodeID(id) - api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) -} - -func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) } -func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { - api.setFavorite(w, r, false) -} - -func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - repo, resource := api.resolveAnnotated(w, r, id) - if repo == nil { - return - } - if err := repo.SetRating(r.Context(), rating, id); err != nil { - api.internalError(w, r, err) - return - } - api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) - encodedID := dto.EncodeID(id) - d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} - if rating > 0 { - jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData - d.Rating = &jfRating - } - api.ok(w, r, d) -} - -// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to -// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since -// SetRating(0) is the delete path. -func (api *Router) setRating(w http.ResponseWriter, r *http.Request) { - jfRating := req.Params(r).Float64Or("rating", 0) - jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating - rating := int(math.Round(jfRating / 2)) - if jfRating > 0 { - rating = max(rating, 1) - } - api.setItemRating(w, r, rating) -} - -func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) { - api.setItemRating(w, r, 0) -} diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go deleted file mode 100644 index a436503ba..000000000 --- a/server/jellyfin/annotations_test.go +++ /dev/null @@ -1,319 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Annotations", func() { - var api *Router - var ds *tests.MockDataStore - var broker *fakeEventBroker - // alice has access to library 1 only. - ctxUser := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) - } - - BeforeEach(func() { - ds = &tests.MockDataStore{} - broker = &fakeEventBroker{} - api = &Router{ds: ds, broker: broker} - }) - - Describe("markFavorite / unmarkFavorite", func() { - It("stars a song and returns IsFavorite=true", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.IsFavorite).To(BeTrue()) - Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue()) - }) - - It("stars an album and returns IsFavorite=true", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.IsFavorite).To(BeTrue()) - Expect(albumRepo.Data[testID("a1")].Starred).To(BeTrue()) - }) - - It("stars an artist without checking library access (artists span multiple libraries)", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - // alice only has access to library 1, but artists aren't gated per-library. - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/ar1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("ar1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.IsFavorite).To(BeTrue()) - Expect(artistRepo.Data[testID("ar1")].Starred).To(BeTrue()) - }) - - It("stars a visible playlist", func() { - playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo) - playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(playlistRepo.Starred[testID("p1")]).To(BeTrue()) - }) - - It("unstars a song and returns IsFavorite=false", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.unmarkFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.IsFavorite).To(BeFalse()) - Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse()) - }) - - It("returns 404 and does not star an album in a library the user can't access", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse()) - }) - - It("returns 404 and does not star a song in a library the user can't access", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse()) - }) - - It("returns 404 when the id doesn't match any entity", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/missing", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 500 (not 404) when a repository lookup fails for a reason other than not-found", func() { - ds.Album().(*tests.MockAlbumRepo).SetError(true) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("x1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - - It("emits a refreshResource event when starring a song", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.markFavorite, w, r) - Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`)) - }) - - It("emits a refreshResource event when starring an album", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.markFavorite, w, r) - Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["` + testID("a1") + `"]}`)) - }) - - It("does not emit an event when the item is not accessible", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(broker.Events).To(BeEmpty()) - }) - }) - - Describe("setRating / removeRating", func() { - It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.Rating).NotTo(BeNil()) - Expect(*d.Rating).To(Equal(8.0)) - }) - - It("rates an album", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(5)) - }) - - It("rates a visible playlist", func() { - playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo) - playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("p1"))+"/Rating?Rating=8", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(playlistRepo.Ratings[testID("p1")]).To(Equal(4)) - }) - - It("removes a rating", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.removeRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.Rating).To(BeNil()) - }) - - It("returns 404 and does not rate an album in a library the user can't access", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0)) - }) - - It("rounds an odd rating to the nearest star instead of truncating", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5)) - }) - - It("stores the minimum star for Rating=1 instead of clearing the rating", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1)) - }) - - It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) - }) - - It("clamps a Rating above 10 to Navidrome's max (5)", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5)) - }) - - It("clamps a negative Rating to Navidrome's min (0)", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0)) - }) - - It("emits a refreshResource event when rating a song", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`)) - }) - }) -}) - -type fakeEventBroker struct { - http.Handler - Events []events.Event -} - -func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { - f.Events = append(f.Events, event) -} - -func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { - f.Events = append(f.Events, event) -} - -var _ events.Broker = (*fakeEventBroker)(nil) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go deleted file mode 100644 index 4a471d1f0..000000000 --- a/server/jellyfin/api.go +++ /dev/null @@ -1,265 +0,0 @@ -package jellyfin - -import ( - "encoding/json" - "net/http" - "time" - - "github.com/go-chi/chi/v5" - "golang.org/x/sync/singleflight" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/lyrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/sonic" - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/cache" -) - -type Router struct { - http.Handler - ds model.DataStore - artwork artwork.Artwork - streamer stream.MediaStreamer - transcodeDecider stream.TranscodeDecider - players core.Players - scrobbler scrobbler.PlayTracker - playlists playlists.Playlists - provider external.Provider - sonic sonic.Engine - lyrics lyrics.Lyrics - broker events.Broker - lyricsCache cache.SimpleCache[string, model.LyricList] - similarFlight singleflight.Group - quickConnect quickconnect.QuickConnect - serverIDVal string -} - -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, - transcodeDecider stream.TranscodeDecider, players core.Players, - scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, - sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker, quickConnect quickconnect.QuickConnect) *Router { - r := &Router{ - ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, - players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, quickConnect: quickConnect, - lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ - SizeLimit: 1000, - DefaultTTL: 5 * time.Minute, - }), - } - r.Handler = r.routes() - return r -} - -func (api *Router) routes() http.Handler { - inner := chi.NewRouter() - - // Read query params case-insensitively, like real Jellyfin. Must precede all routes so every - // handler and the api_key check see folded keys. - inner.Use(normalizeQueryKeys) - - // Routes are lowercase; caseInsensitivePaths lowercases the request path. Keep new routes lowercase. - - // Public (no auth): handshake + login. - inner.Get("/system/info/public", api.getPublicSystemInfo) - inner.Get("/system/ping", api.ping) - inner.Post("/system/ping", api.ping) - inner.Get("/quickconnect/enabled", api.quickConnectEnabled) - // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated - // brute-force surface, so it must share the same per-client throttle when one is configured. - login := inner.With(server.LimitLoginBody) - if conf.Server.AuthRequestLimit > 0 { - login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) - } - login.Post("/users/authenticatebyname", api.authenticateByName) - quickConnectLogin := login.With(requireQuickConnect) - quickConnectLogin.Post("/quickconnect/initiate", api.quickConnectInitiate) - quickConnectLogin.Post("/users/authenticatewithquickconnect", api.authenticateWithQuickConnect) - // Not rate-limited: Finamp and Streamyfin poll it every second while the code is shown. - inner.With(requireQuickConnect).Get("/quickconnect/connect", api.quickConnectConnect) - inner.Get("/users/public", api.getPublicUsers) - - // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. - // Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy, - // and an unbounded burst (a client fetching artwork across a large library) can exhaust memory. - inner.Group(func(r chi.Router) { - r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, - conf.Server.DevArtworkThrottleBacklogTimeout)) - r.Get("/items/{itemId}/images/{type}", api.getItemImage) - r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage) - r.Head("/items/{itemId}/images/{type}", api.getItemImage) - r.Head("/items/{itemId}/images/{type}/{index}", api.getItemImage) - }) - - inner.Group(func(r chi.Router) { - r.Use(api.authenticate) - // Register/refresh the calling device as a player on every authenticated request, like - // Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a - // player) even before the first playback report. - r.Use(api.withPlayer) - r.Get("/system/info", api.getSystemInfo) - r.Get("/system/endpoint", api.getEndpointInfo) - r.Get("/userviews", api.getUserViews) - r.Get("/users/{userId}/views", api.getUserViews) - r.Get("/users/me", api.getCurrentUser) - r.Get("/users/{userId}", api.getCurrentUser) - // Throttled like login so a signed-in user cannot enumerate other people's pending codes. - approve := r.With(requireQuickConnect) - if conf.Server.AuthRequestLimit > 0 { - approve = approve.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) - } - approve.Post("/quickconnect/authorize", api.quickConnectAuthorize) - - // Cursor-backed collections: each streams straight from the DB, holding a connection for the - // whole client-paced response, so enough slow clients would take the entire pool and stall the - // scanner, scrobbles and the UI. Cap them at half the pool (see conf.MaxOpenConns); excess - // requests queue rather than fail. - r.Group(func(r chi.Router) { - r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) - r.Get("/items", api.getItems) - r.Get("/users/{userId}/items", api.getItems) - r.Get("/items/latest", api.getLatest) - r.Get("/users/{userId}/items/latest", api.getLatest) - r.Get("/artists", api.getArtists) - r.Get("/artists/albumartists", api.getAlbumArtists) - r.Get("/playlists/{playlistId}/items", api.getPlaylistItems) - }) - - r.Get("/items/{itemId}", api.getItem) - r.Get("/users/{userId}/items/{itemId}", api.getItem) - r.Delete("/items/{itemId}", api.deleteItem) - - // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the - // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. - r.Post("/userfavoriteitems/{itemId}", api.markFavorite) - r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite) - r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite) - r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite) - r.Post("/users/{userId}/items/{itemId}/rating", api.setRating) - r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating) - - // Per-item play/favorite/rating state. Jellify uses the /UserItems form; - // /Users/{userId}/Items is the legacy spelling. - r.Get("/useritems/{itemId}/userdata", api.getUserItemData) - r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData) - - r.Get("/artists/{itemId}/similar", api.getSimilarArtists) - r.Get("/items/{itemId}/similar", api.getSimilarItems) - r.Get("/albums/{itemId}/similar", api.getSimilarAlbums) - r.Get("/items/{itemId}/instantmix", api.getInstantMix) - r.Get("/songs/{itemId}/instantmix", api.getInstantMix) - r.Get("/albums/{itemId}/instantmix", api.getInstantMix) - r.Get("/artists/{itemId}/instantmix", api.getInstantMix) - r.Get("/playlists/{itemId}/instantmix", api.getInstantMix) - r.Get("/artists/instantmix", api.getInstantMixByQuery) - r.Get("/musicgenres/instantmix", api.getInstantMixByQuery) - r.Get("/genres", api.getGenres) - r.Get("/musicgenres", api.getGenres) - r.Get("/studios", api.getStudios) - r.Get("/items/filters", api.getQueryFiltersLegacy) - - r.Post("/playlists", api.createPlaylist) - r.Get("/playlists/{playlistId}", api.getPlaylist) - r.Post("/playlists/{playlistId}", api.updatePlaylist) - r.Post("/playlists/{playlistId}/items", api.addToPlaylist) - r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist) - r.Post("/playlists/{playlistId}/items/{entryId}/move/{newIndex}", api.movePlaylistItem) - r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers) - r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser) - - // Cover upload/delete: only playlists are writable (see postItemImage); the GET routes - // above stay public. - r.Post("/items/{itemId}/images/{type}", api.postItemImage) - r.Delete("/items/{itemId}/images/{type}", api.deleteItemImage) - - r.Get("/audio/{itemId}/stream", api.streamAudio) - r.Get("/audio/{itemId}/stream.{container}", api.streamAudio) - r.Get("/audio/{itemId}/universal", api.streamUniversal) - // Fintunes probes these with HEAD for the content type before playing or downloading. - r.Head("/audio/{itemId}/stream", api.streamAudio) - r.Head("/audio/{itemId}/stream.{container}", api.streamAudio) - r.Head("/audio/{itemId}/universal", api.streamUniversal) - r.Get("/audio/{itemId}/main.m3u8", api.streamHls) - r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) - r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) - r.Get("/audio/{itemId}/lyrics", api.getLyrics) - // Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of - // /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file. - r.Get("/items/{itemId}/file", api.streamFile) - r.Get("/items/{itemId}/download", api.streamFile) - r.Head("/items/{itemId}/file", api.streamFile) - r.Head("/items/{itemId}/download", api.streamFile) - - r.Post("/sessions/playing", api.reportPlaybackStart) - r.Post("/sessions/playing/progress", api.reportPlaybackProgress) - r.Post("/sessions/playing/stopped", api.reportPlaybackStopped) - r.Post("/sessions/playing/ping", api.acknowledge) - r.Post("/sessions/capabilities", api.acknowledge) - r.Post("/sessions/capabilities/full", api.acknowledge) - - // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. - r.Get("/socket", api.handleSocket) - - r.Get("/audiomuseai/info", api.audioMuseInfo) - r.Get("/audiomuseai/health", api.audioMuseHealth) - r.Get("/audiomuseai/similar_tracks", api.audioMuseSimilarTracks) - r.Get("/audiomuseai/find_path", api.audioMuseFindPath) - }) - - // Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected - // traffic, and this just surfaces what's missing. - inner.NotFound(api.notFound) - inner.MethodNotAllowed(api.notFound) - - // Real Jellyfin clients route case-insensitively; chi does not. - return caseInsensitivePaths(inner) -} - -// ok writes payload as JSON — the single entry point for every handler. Collections are routed to -// the streaming writer, so callers needn't know whether theirs is cursor-backed. ServerId is stamped -// on any item(s): real Jellyfin always sets it, and it's constant per request. -// -// Only /Items/Latest bypasses this, for its bare-array shape (see writeItemsArray). -func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { - switch p := payload.(type) { - case itemsResult: - api.writeItems(w, r, p) - return - case dto.QueryResult: - api.writeItems(w, r, materialized(p)) - return - case dto.BaseItemDto: - payload = stampItem(p, api.serverID(r.Context()), requestFields(r)) - } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - if err := json.NewEncoder(w).Encode(payload); err != nil { - log.Error(r.Context(), "Jellyfin API: error encoding response", err) - } -} - -// notFound handles unmatched routes and unsupported methods, logging them so unimplemented -// endpoints surface instead of returning chi's default plain-text 404/405. -func (api *Router) notFound(w http.ResponseWriter, r *http.Request) { - log.Debug(r.Context(), "Jellyfin API: unhandled route", "method", r.Method, "path", r.URL.Path) - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{}`)) -} - -// internalError logs the real error and writes a generic 500, so internal detail (ffmpeg output, -// file paths) never reaches the client. -func (api *Router) internalError(w http.ResponseWriter, r *http.Request, err error) { - log.Error(r.Context(), "Jellyfin API: internal error", "method", r.Method, "path", r.URL.Path, err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) -} diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go deleted file mode 100644 index 0c6ac9b98..000000000 --- a/server/jellyfin/api_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package jellyfin - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "time" - - "github.com/go-chi/chi/v5/middleware" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Router", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - It("serves the public handshake through the mounted handler", func() { - ds := &tests.MockDataStore{} - api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Info/Public", nil) - api.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("returns 404 JSON for unknown routes", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) - api.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) - Expect(w.Body.String()).To(Equal("{}")) - }) - - It("returns 404 JSON for a known path with an unsupported method", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - w := httptest.NewRecorder() - r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) - api.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(w.Body.String()).To(Equal("{}")) - }) - - It("registers a player on a general authenticated request, not just playback reports", func() { - ds := &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) - token, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"}) - Expect(err).ToNot(HaveOccurred()) - - fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil, nil) - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Jellify", Device="Phone", DeviceId="dev-1", Version="1.0"`) - r.Header.Set("X-Emby-Token", token) - api.ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fp.registerCalls).To(Equal(1)) - Expect(fp.lastClient).To(Equal("Jellify")) - }) - - It("rate-limits AuthenticateByName by IP when a login limit is configured", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 2 - conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - - login := func() int { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`)) - r.RemoteAddr = "10.0.0.1:1234" - api.ServeHTTP(w, r) - return w.Code - } - // The bad credentials would be 401; the limiter cuts in on the 3rd attempt with 429. - Expect(login()).To(Equal(http.StatusUnauthorized)) - Expect(login()).To(Equal(http.StatusUnauthorized)) - Expect(login()).To(Equal(http.StatusTooManyRequests)) - }) - - It("rate-limits Quick Connect approval by IP when a login limit is configured", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 2 - conf.Server.AuthWindowLength = time.Minute - conf.Server.Jellyfin.QuickConnect = true - ds := &tests.MockDataStore{} - auth.Init(ds) - usr := model.User{ID: testID("alice"), UserName: "alice"} - Expect(ds.User().Put(ctx, &usr)).To(Succeed()) - token, err := auth.CreateAPIToken(&usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, quickconnect.New()) - - authorize := func() int { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/QuickConnect/Authorize?code=000000", nil) - r.RemoteAddr = "10.0.0.1:1234" - r.Header.Set("X-Emby-Token", token) - api.ServeHTTP(w, r) - return w.Code - } - // An unknown code is 404; the limiter cuts in on the 3rd attempt with 429. - Expect(authorize()).To(Equal(http.StatusNotFound)) - Expect(authorize()).To(Equal(http.StatusNotFound)) - Expect(authorize()).To(Equal(http.StatusTooManyRequests)) - }) - - It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 1 - conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - // Every request arrives on the same proxy connection, so only the resolved client IP - // can separate the buckets. - handler := middleware.ClientIPFromHeader("X-Real-IP")(api) - - login := func(clientIP string) int { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`)) - r.RemoteAddr = "10.0.0.1:1234" - r.Header.Set("X-Real-IP", clientIP) - handler.ServeHTTP(w, r) - return w.Code - } - Expect(login("203.0.113.1")).To(Equal(http.StatusUnauthorized)) - Expect(login("203.0.113.1")).To(Equal(http.StatusTooManyRequests)) - Expect(login("203.0.113.2")).To(Equal(http.StatusUnauthorized)) - }) -}) diff --git a/server/jellyfin/audiomuse.go b/server/jellyfin/audiomuse.go deleted file mode 100644 index 4cc1a2cc5..000000000 --- a/server/jellyfin/audiomuse.go +++ /dev/null @@ -1,167 +0,0 @@ -package jellyfin - -import ( - "net/http" - "strings" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" -) - -// audioMuseEndpoints is what /AudioMuseAI/info advertises; it omits info itself, like the plugin, -// and is sorted the same way (the plugin builds it with OrderBy). -var audioMuseEndpoints = []string{ - "GET /AudioMuseAI/find_path", - "GET /AudioMuseAI/health", - "GET /AudioMuseAI/similar_tracks", -} - -type audioMuseInfoResponse struct { - Version string `json:"Version"` - AvailableEndpoints []string `json:"AvailableEndpoints"` -} - -func (api *Router) audioMuseInfo(w http.ResponseWriter, r *http.Request) { - endpoints := []string{} // non-nil so an empty list serializes as [], not null - if api.sonic != nil && api.sonic.HasProvider() { - endpoints = audioMuseEndpoints - } - api.ok(w, r, audioMuseInfoResponse{ - Version: consts.Version, - AvailableEndpoints: endpoints, - }) -} - -// audioMuseHealth is a liveness probe: 200 with an empty body when a sonic provider is loaded, else -// 404 — mirroring the reference plugin, which returns 200 when its backend is reachable. -func (api *Router) audioMuseHealth(w http.ResponseWriter, r *http.Request) { - if api.sonic == nil || !api.sonic.HasProvider() { - api.notFound(w, r) - return - } - w.WriteHeader(http.StatusOK) -} - -type audioMuseSimilarTrack struct { - Author string `json:"author"` - Distance float64 `json:"distance"` - ItemID string `json:"item_id"` - Title string `json:"title"` -} - -func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - // 404 without a provider, like the Subsonic sonicSimilarity handlers. - if api.sonic == nil || !api.sonic.HasProvider() { - api.notFound(w, r) - return - } - p := req.Params(r) - tracks := []audioMuseSimilarTrack{} - - itemID := p.StringOr("item_id", "") - id, ok := dto.DecodeID(itemID) - if !ok { - // Every other failure here (no provider, no match) degrades to an empty list rather than an - // error, so a malformed id does too instead of being the one path that 404s. - api.ok(w, r, tracks) - return - } - n := min(p.IntOr("n", 10), maxSimilarLimit) // cap a user-controlled count, like clampLimit - eliminateDuplicates := p.BoolOr("eliminate_duplicates", true) - - matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, n) - if err != nil { - api.ok(w, r, tracks) - return - } - - u, _ := request.UserFrom(ctx) - seenArtists := make(map[string]bool, len(matches)) - for _, m := range matches { - mf := m.MediaFile - if !u.HasLibraryAccess(mf.LibraryID) { - continue - } - if eliminateDuplicates { - key := strings.ToLower(mf.Artist) - if seenArtists[key] { - continue - } - seenArtists[key] = true - } - tracks = append(tracks, audioMuseSimilarTrack{ - Author: mf.Artist, - Distance: m.Similarity, - ItemID: dto.EncodeID(mf.ID), - Title: mf.Title, - }) - } - api.ok(w, r, tracks) -} - -type audioMusePathTrack struct { - Author string `json:"author"` - ItemID string `json:"item_id"` - Title string `json:"title"` - Tempo *float64 `json:"tempo,omitempty"` -} - -type audioMusePathResponse struct { - Path []audioMusePathTrack `json:"path"` - TotalDistance float64 `json:"total_distance"` -} - -func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if api.sonic == nil || !api.sonic.HasProvider() { - api.notFound(w, r) - return - } - p := req.Params(r) - - startID := p.StringOr("start_song_id", "") - endID := p.StringOr("end_song_id", "") - if startID == "" || endID == "" { - http.Error(w, "start_song_id and end_song_id are required.", http.StatusBadRequest) - return - } - - resp := audioMusePathResponse{Path: []audioMusePathTrack{}} - startDecoded, startOk := dto.DecodeID(startID) - endDecoded, endOk := dto.DecodeID(endID) - if !startOk || !endOk { - // This endpoint already 400s when an endpoint id is absent, so an unusable one reports the - // same way rather than looking like a successful search that found no path. - http.Error(w, "start_song_id and end_song_id must be valid item ids.", http.StatusBadRequest) - return - } - maxSteps := min(p.IntOr("max_steps", 25), maxSimilarLimit) // cap a user-controlled count - matches, err := api.sonic.FindSonicPath(ctx, startDecoded, endDecoded, maxSteps) - if err != nil { - api.ok(w, r, resp) - return - } - - u, _ := request.UserFrom(ctx) - for _, m := range matches { - mf := m.MediaFile - if !u.HasLibraryAccess(mf.LibraryID) { - continue - } - track := audioMusePathTrack{ - Author: mf.Artist, - ItemID: dto.EncodeID(mf.ID), - Title: mf.Title, - } - if mf.BPM != nil { - tempo := float64(*mf.BPM) - track.Tempo = &tempo - } - resp.Path = append(resp.Path, track) - resp.TotalDistance += m.Similarity - } - api.ok(w, r, resp) -} diff --git a/server/jellyfin/audiomuse_test.go b/server/jellyfin/audiomuse_test.go deleted file mode 100644 index 6815753fd..000000000 --- a/server/jellyfin/audiomuse_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/sonic" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("AudioMuse info", func() { - It("lists the sonic endpoints (excluding info) when a provider is present", func() { - api := &Router{sonic: &fakeSonicEngine{provider: true}} - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) - - api.audioMuseInfo(w, r) - - Expect(w.Code).To(Equal(200)) - var body audioMuseInfoResponse - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body.Version).To(Equal(consts.Version)) - Expect(body.AvailableEndpoints).To(ConsistOf( - "GET /AudioMuseAI/find_path", - "GET /AudioMuseAI/health", - "GET /AudioMuseAI/similar_tracks", - )) - }) - - It("returns an empty endpoint list when no provider is loaded", func() { - api := &Router{} - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) - - api.audioMuseInfo(w, r) - - Expect(w.Code).To(Equal(200)) - var body audioMuseInfoResponse - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body.AvailableEndpoints).To(BeEmpty()) - Expect(w.Body.String()).To(ContainSubstring(`"AvailableEndpoints":[]`)) - }) -}) - -type fakeSonicEngine struct { - provider bool - similar []sonic.SimilarMatch - similarErr error - path []sonic.SimilarMatch - pathErr error - gotID string - gotStart string - gotEnd string - gotCount int -} - -func (f *fakeSonicEngine) HasProvider() bool { return f.provider } - -func (f *fakeSonicEngine) GetSonicSimilarTracks(_ context.Context, id string, count int) ([]sonic.SimilarMatch, error) { - f.gotID, f.gotCount = id, count - return f.similar, f.similarErr -} - -func (f *fakeSonicEngine) FindSonicPath(_ context.Context, startID, endID string, count int) ([]sonic.SimilarMatch, error) { - f.gotStart, f.gotEnd, f.gotCount = startID, endID, count - return f.path, f.pathErr -} - -func mf(id, artist, title string, lib int) model.MediaFile { - return model.MediaFile{ID: id, Artist: artist, Title: title, LibraryID: lib} -} - -var _ = Describe("AudioMuse health", func() { - It("returns 200 with an empty body when a provider is loaded", func() { - api := &Router{sonic: &fakeSonicEngine{provider: true}} - w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(200)) - Expect(w.Body.Len()).To(Equal(0)) - }) - - It("returns 404 when no provider is loaded", func() { - api := &Router{} - w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(404)) - }) -}) - -// audioMuseGet drives a GET through normalizeQueryKeys as the given user, mirroring a real request. -func audioMuseGet(handler http.HandlerFunc, path, query string, user model.User) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", path+"?"+query, nil) - r = r.WithContext(request.WithUser(r.Context(), user)) - invoke(handler, w, r) - return w -} - -var _ = Describe("AudioMuse similar_tracks", func() { - var fake *fakeSonicEngine - var api *Router - - call := func(query string, user model.User) *httptest.ResponseRecorder { - return audioMuseGet(api.audioMuseSimilarTracks, "/AudioMuseAI/similar_tracks", query, user) - } - - BeforeEach(func() { - fake = &fakeSonicEngine{provider: true} - api = &Router{sonic: fake} - }) - - It("maps matches, decodes the seed id, encodes item ids, copies distance", func() { - fake.similar = []sonic.SimilarMatch{ - {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, - {MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 0.5}, - } - w := call("item_id="+dto.EncodeID(testID("seed"))+"&n=5", model.User{IsAdmin: true}) - - Expect(w.Code).To(Equal(200)) - Expect(fake.gotID).To(Equal(testID("seed"))) - Expect(fake.gotCount).To(Equal(5)) - var body []audioMuseSimilarTrack - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body).To(HaveLen(2)) - Expect(body[0]).To(Equal(audioMuseSimilarTrack{ - Author: "A", Distance: 0.3, ItemID: dto.EncodeID(testID("mf1")), Title: "T1", - })) - }) - - It("collapses to one track per artist when eliminate_duplicates defaults on", func() { - fake.similar = []sonic.SimilarMatch{ - {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, - {MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5}, - } - w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true}) - var body []audioMuseSimilarTrack - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body).To(HaveLen(1)) - }) - - It("keeps same-artist tracks when eliminate_duplicates=false", func() { - fake.similar = []sonic.SimilarMatch{ - {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, - {MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5}, - } - w := call("item_id="+dto.EncodeID(testID("seed"))+"&eliminate_duplicates=false", model.User{IsAdmin: true}) - var body []audioMuseSimilarTrack - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body).To(HaveLen(2)) - }) - - It("filters out tracks in libraries the user cannot access", func() { - fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 2), Similarity: 0.3}} - w := call("item_id="+dto.EncodeID(testID("seed")), model.User{Libraries: model.Libraries{{ID: 1}}}) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) - }) - - It("returns an empty array without calling the engine when item_id is missing", func() { - w := call("n=5", model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(200)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) - Expect(fake.gotID).To(Equal("")) - }) - - // A malformed item_id degrades the same way as a missing one (200, empty array) rather than - // 404ing: every other failure on this endpoint already degrades gracefully. - It("returns an empty array without calling the engine when item_id is malformed", func() { - w := call("item_id=not-a-valid-id", model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(200)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) - Expect(fake.gotID).To(Equal("")) - }) - - It("returns 404 when no sonic provider is loaded", func() { - fake.provider = false - w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(404)) - }) - - It("returns an empty array when the engine errors", func() { - fake.similarErr = errors.New("boom") - fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}} - w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(200)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) - }) -}) - -var _ = Describe("AudioMuse find_path", func() { - var fake *fakeSonicEngine - var api *Router - - call := func(query string, user model.User) *httptest.ResponseRecorder { - return audioMuseGet(api.audioMuseFindPath, "/AudioMuseAI/find_path", query, user) - } - - BeforeEach(func() { - fake = &fakeSonicEngine{provider: true} - api = &Router{sonic: fake} - }) - - It("returns 400 with the exact message when start_song_id is missing", func() { - w := call("end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(400)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) - }) - - It("returns 400 when end_song_id is missing", func() { - w := call("start_song_id="+dto.EncodeID(testID("s")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(400)) - }) - - It("maps the path, decodes ids, sums total_distance, fills tempo from BPM", func() { - bpm := 120 - withBPM := mf(testID("mf1"), "A", "T1", 1) - withBPM.BPM = &bpm - fake.path = []sonic.SimilarMatch{ - {MediaFile: withBPM, Similarity: 1.5}, - {MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 2.0}, - } - w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e"))+"&max_steps=10", model.User{IsAdmin: true}) - - Expect(w.Code).To(Equal(200)) - Expect(fake.gotStart).To(Equal(testID("s"))) - Expect(fake.gotEnd).To(Equal(testID("e"))) - Expect(fake.gotCount).To(Equal(10)) - var body audioMusePathResponse - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body.Path).To(HaveLen(2)) - Expect(body.TotalDistance).To(Equal(3.5)) - Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID(testID("mf1")))) - Expect(*body.Path[0].Tempo).To(Equal(120.0)) - Expect(body.Path[1].Tempo).To(BeNil()) - }) - - It("returns 404 when no sonic provider is loaded", func() { - fake.provider = false - w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(404)) - }) - - It("returns an empty path object when the engine errors", func() { - fake.pathErr = errors.New("boom") - fake.path = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 1.0}} - w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(200)) - var body audioMusePathResponse - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body.Path).To(BeEmpty()) - Expect(body.TotalDistance).To(Equal(0.0)) - }) - - It("400s without calling the engine when an id is malformed", func() { - w := call("start_song_id=not-a-valid-id&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(fake.gotStart).To(Equal("")) - Expect(fake.gotEnd).To(Equal("")) - }) -}) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go deleted file mode 100644 index 264d5b6ca..000000000 --- a/server/jellyfin/auth.go +++ /dev/null @@ -1,140 +0,0 @@ -package jellyfin - -import ( - "encoding/json" - "net/http" - - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -type authenticateByNameRequest struct { - Username string `json:"Username"` - Pw string `json:"Pw"` -} - -func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - var body authenticateByNameRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - - // Navidrome stores recoverable passwords; this mirrors Subsonic's validateCredentials plaintext path. - usr, err := api.ds.User().FindByUsernameWithPassword(ctx, body.Username) - if body.Pw == "" || err != nil || usr == nil || usr.Password != body.Pw { - log.Warn(ctx, "Jellyfin API: invalid login", "username", body.Username, "remoteAddr", r.RemoteAddr) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - api.signIn(w, r, usr) -} - -func (api *Router) signIn(w http.ResponseWriter, r *http.Request, usr *model.User) { - ctx := r.Context() - // Best-effort, like the web UI's validateLogin: without it, Jellyfin-only users show a - // never/stale "Last Login" in the admin UI. - if err := api.ds.User().UpdateLastLoginAt(ctx, usr.ID); err != nil { - log.Error(ctx, "Jellyfin API: could not update last login date", "username", usr.UserName, err) - } - - token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - if err != nil { - api.internalError(w, r, err) - return - } - - a := parseMediaBrowserAuth(r) - serverID := api.serverID(ctx) - api.ok(w, r, dto.AuthenticationResult{ - User: userToDto(usr, serverName(), serverID), - SessionInfo: dto.NewSessionInfo(usr, a.Client, a.DeviceId, a.Device, a.Version, serverID), - AccessToken: token, - ServerId: serverID, - }) -} - -// userToDto builds the User object clients expect. Finamp reads Policy and Configuration right after -// login and null-crashes if absent, so both are filled with Navidrome-appropriate defaults. -func userToDto(u *model.User, serverName, serverID string) *dto.UserDto { - return &dto.UserDto{ - Name: u.UserName, - Id: dto.EncodeID(u.ID), // hex like every other id, so lowercased paths stay valid - ServerId: serverID, - ServerName: serverName, - HasPassword: true, - HasConfiguredPassword: true, - Policy: userPolicy(u), - Configuration: userConfiguration(), - } -} - -func userPolicy(u *model.User) *dto.UserPolicy { - return &dto.UserPolicy{ - IsAdministrator: u.IsAdmin, - IsHidden: false, - EnableCollectionManagement: false, - EnableSubtitleManagement: false, - EnableLyricManagement: false, - IsDisabled: false, - BlockedTags: []string{}, - AllowedTags: []string{}, - EnableUserPreferenceAccess: true, - AccessSchedules: []string{}, - BlockUnratedItems: []string{}, - EnableRemoteControlOfOtherUsers: false, - EnableSharedDeviceControl: false, - EnableRemoteAccess: true, - EnableLiveTvManagement: false, - EnableLiveTvAccess: false, - EnableMediaPlayback: true, - EnableAudioPlaybackTranscoding: true, - EnableVideoPlaybackTranscoding: true, - EnablePlaybackRemuxing: true, - ForceRemoteSourceTranscoding: false, - EnableContentDeletion: false, - EnableContentDeletionFromFolders: []string{}, - EnableContentDownloading: true, - EnableSyncTranscoding: true, - EnableMediaConversion: true, - EnabledDevices: []string{}, - EnableAllDevices: true, - EnabledChannels: []string{}, - EnableAllChannels: false, - EnabledFolders: []string{}, - EnableAllFolders: true, - InvalidLoginAttemptCount: 0, - LoginAttemptsBeforeLockout: -1, - MaxActiveSessions: 0, - EnablePublicSharing: true, - BlockedMediaFolders: []string{}, - BlockedChannels: []string{}, - RemoteClientBitrateLimit: 0, - AuthenticationProviderId: "", - PasswordResetProviderId: "", - SyncPlayAccess: "CreateAndJoinGroups", - } -} - -func userConfiguration() *dto.UserConfiguration { - return &dto.UserConfiguration{ - PlayDefaultAudioTrack: true, - SubtitleLanguagePreference: "", - DisplayMissingEpisodes: false, - GroupedFolders: []string{}, - SubtitleMode: "Default", - DisplayCollectionsView: false, - EnableLocalPassword: false, - OrderedViews: []string{}, - LatestItemsExcludes: []string{}, - MyMediaExcludes: []string{}, - HidePlayedInLatest: true, - RememberAudioSelections: true, - RememberSubtitleSelections: true, - EnableNextEpisodeAutoPlay: true, - CastReceiverId: "", - } -} diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go deleted file mode 100644 index dafad9244..000000000 --- a/server/jellyfin/auth_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("AuthenticateByName", func() { - var ctx context.Context - var api *Router - var ds *tests.MockDataStore - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) - api = &Router{ds: ds} - }) - - It("issues a token for valid credentials", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) - api.authenticateByName(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.AuthenticationResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.AccessToken).ToNot(BeEmpty()) - Expect(res.User.Name).To(Equal("alice")) - claims, err := auth.Validate(res.AccessToken) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Subject).To(Equal("alice")) - - // Finamp reads Policy/Configuration right after login and null-crashes if they're absent. - Expect(res.User.Policy).ToNot(BeNil()) - Expect(res.User.Policy.IsAdministrator).To(BeFalse()) - Expect(res.User.Policy.EnableAllFolders).To(BeTrue()) - Expect(res.User.Policy.EnableMediaPlayback).To(BeTrue()) - Expect(res.User.Configuration).ToNot(BeNil()) - }) - - Describe("SessionInfo", func() { - login := func(authHeader string) map[string]any { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) - r.Header.Set("Authorization", authHeader) - api.authenticateByName(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var raw map[string]any - Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed()) - Expect(raw).To(HaveKey("SessionInfo")) - return raw["SessionInfo"].(map[string]any) - } - const jellybox = `MediaBrowser Client="JellyBox", Device="Mac", DeviceId="dev-1", Version="2.1"` - - It("has the fields strict clients require", func() { - s := login(jellybox) - Expect(s["Id"]).To(And(BeAssignableToTypeOf(""), Not(BeEmpty()))) - Expect(s["UserId"]).To(Equal(dto.EncodeID(testID("u1")))) - Expect(s["LastActivityDate"]).To(And(BeAssignableToTypeOf(""), Not(BeEmpty()))) - for _, k := range []string{"SupportsRemoteControl", "SupportsMediaControl", "HasCustomDeviceName"} { - Expect(s[k]).To(BeAssignableToTypeOf(false), k) - } - ps, ok := s["PlayState"].(map[string]any) - Expect(ok).To(BeTrue()) - for _, k := range []string{"CanSeek", "IsPaused", "IsMuted"} { - Expect(ps[k]).To(BeAssignableToTypeOf(false), k) - } - }) - - It("describes the calling client", func() { - s := login(jellybox) - Expect(s).To(HaveKeyWithValue("UserName", "alice")) - Expect(s).To(HaveKeyWithValue("Client", "JellyBox")) - Expect(s).To(HaveKeyWithValue("DeviceName", "Mac")) - Expect(s).To(HaveKeyWithValue("DeviceId", "dev-1")) - Expect(s).To(HaveKeyWithValue("ApplicationVersion", "2.1")) - Expect(s).To(HaveKeyWithValue("IsActive", true)) - }) - - It("keeps the same Id for the same device, and a new one for another device", func() { - first := login(jellybox)["Id"] - Expect(login(jellybox)["Id"]).To(Equal(first)) - other := login(`MediaBrowser Client="JellyBox", Device="Mac", DeviceId="dev-2", Version="2.1"`) - Expect(other["Id"]).ToNot(Equal(first)) - }) - }) - - It("records the login time, like the web UI login does", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) - api.authenticateByName(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - ur := ds.User().(*tests.MockedUserRepo) - usr, err := ur.FindByUsername(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - Expect(usr.LastLoginAt).ToNot(BeNil()) - }) - - It("reflects an administrator in the User.Policy", func() { - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("admin1"), UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed()) - - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"root","Pw":"secret"}`)) - api.authenticateByName(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.AuthenticationResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.User.Policy).ToNot(BeNil()) - Expect(res.User.Policy.IsAdministrator).To(BeTrue()) - }) - - It("rejects invalid credentials with 401", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"alice","Pw":"wrong"}`)) - api.authenticateByName(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects an empty password even for a user with an empty stored password with 401", func() { - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("e"), UserName: "empty", NewPassword: ""})).To(Succeed()) - - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"empty","Pw":""}`)) - api.authenticateByName(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) -}) - -var _ = Describe("AuthenticateByName body limit", func() { - It("rejects a request body larger than the limit", func() { - ds := &tests.MockDataStore{} - api := &Router{ds: ds} - w := httptest.NewRecorder() - body := `{"Username":"alice","Pw":"secret","Padding":"` + strings.Repeat("x", server.MaxLoginBodySize) + `"}` - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(body)) - server.LimitLoginBody(http.HandlerFunc(api.authenticateByName)).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) -}) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go deleted file mode 100644 index baebbf0f3..000000000 --- a/server/jellyfin/browsing.go +++ /dev/null @@ -1,116 +0,0 @@ -package jellyfin - -import ( - "net/http" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" -) - -// getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists -// handles GET /Artists/AlbumArtists (album artists only). Distinct roles, so composers/arrangers -// don't appear identically in both. -func (api *Router) getArtists(w http.ResponseWriter, r *http.Request) { - api.listArtistsByRole(w, r, model.RoleArtist) -} - -func (api *Router) getAlbumArtists(w http.ResponseWriter, r *http.Request) { - api.listArtistsByRole(w, r, model.RoleAlbumArtist) -} - -// listArtistsByRole is the shared body of the /Artists* handlers, scoping to ParentId's library -// when accessible (like queryItems) or all accessible libraries otherwise. -func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, role model.Role) { - ctx := r.Context() - scopeIDs, _, ok := parentIDScope(ctx, r) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - genreIds, ok := decodedQueryIDs(r, "genreids") - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - // This route resolves its own scope, so it shares only the plain query params with /Items. - q := listParams(req.Params(r)) - q.scopeIDs = scopeIDs - q.genreIds = genreIds - - opts := model.QueryOptions{Offset: q.offset, Max: q.limit} - applySort(&opts, "MusicArtist", q.sortBy, q.sortOrder) - if q.search != "" { - opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit) - } - - res, err := api.listArtists(ctx, opts, q, role) - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, res) -} - -// getGenres handles /Genres and /MusicGenres. Genres are global, so no library scoping applies. -func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - p := req.Params(r) - opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} - res, err := api.listGenres(ctx, opts) - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, res) -} - -// getStudios handles GET /Studios, exposing record labels (Jellyfin's audio "studio" source) as -// Studio items, scoped to ParentId's library when accessible. -func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - p := req.Params(r) - scope, _, ok := parentIDScope(ctx, r) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)} - labels, err := api.ds.Tag().GetAll(ctx, model.TagRecordLabel, opts) - if err != nil { - api.internalError(w, r, err) - return - } - items := slice.Map(labels, dto.StudioToBaseItem) - offset, max := p.IntOr("startindex", 0), p.IntOr("limit", 0) - api.ok(w, r, result(paginate(items, offset, max), len(items), offset)) -} - -// getQueryFiltersLegacy handles GET /Items/Filters. Genres and Years are scoped to ParentId's -// library when accessible. Tags/OfficialRatings have no music source, so they are always empty. -func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - scope, _, ok := parentIDScope(ctx, r) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)} - genres, err := api.ds.Genre().GetAll(ctx, genreOpts) - if err != nil { - api.internalError(w, r, err) - return - } - years, err := api.ds.Album().GetYears(ctx, scope...) - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, dto.QueryFiltersLegacy{ - Genres: slice.Map(genres, func(g model.Genre) string { return g.Name }), - Tags: []string{}, - OfficialRatings: []string{}, - Years: years, - }) -} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go deleted file mode 100644 index 48a62c4e5..000000000 --- a/server/jellyfin/browsing_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Browsing", func() { - var api *Router - var ds *tests.MockDataStore - ctxUser := func(libs model.Libraries) context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs}) - } - - // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. - ctxAdmin := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil}) - } - - BeforeEach(func() { - ds = &tests.MockDataStore{} - api = &Router{ds: ds} - }) - - Describe("getArtists", func() { - It("lists artists via /Artists", func() { - ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Type).To(Equal("MusicArtist")) - }) - - It("handles /Artists/AlbumArtists the same way", func() { - ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - }) - - It("scopes results to the user's accessible libraries", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(libs)) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_artist.library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("scopes to a single library when ParentId is an accessible library id", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(2), nil).WithContext(ctxUser(libs)) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_artist.library_id")) - Expect(args).To(ContainElement(2)) - Expect(args).NotTo(ContainElement(1)) - }) - - It("does not let ParentId= narrow the scope", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}} // no access to library 99 - r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(99), nil).WithContext(ctxUser(libs)) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_artist.library_id")) - Expect(args).To(ContainElement(1)) - Expect(args).NotTo(ContainElement(99)) - }) - - It("forwards SearchTerm to the repo's Search method", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - }) - - It("bounds a search the client left unbounded, and clamps an oversized one", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artistRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) - - w = httptest.NewRecorder() - r = httptest.NewRequest("GET", "/Artists?SearchTerm=art&Limit=999999", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artistRepo.Options.Max).To(Equal(maxSearchLimit + 1)) - }) - - It("forwards StartIndex/Limit as Offset/Max", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists?StartIndex=5&Limit=10", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artistRepo.Options.Offset).To(Equal(5)) - Expect(artistRepo.Options.Max).To(Equal(10)) - }) - - It("does not restrict results for an admin user", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin()) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - // accessibleLibraryIDs is empty for an admin (Libraries is nil), so - // ApplyArtistLibraryFilter([]) is a no-op: no library_id restriction is added. - if artistRepo.Options.Filters == nil { - return - } - sql, _, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).NotTo(ContainSubstring("library_artist.library_id")) - }) - - DescribeTable("restricts to favorites", - func(url string, handler func(*Router) http.HandlerFunc) { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", url, nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(handler(api), w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("starred")) - // listArtists always ANDs notMissing, favorites filter or not. - Expect(sql).To(ContainSubstring("missing")) - Expect(args).To(ContainElement(true)) - }, - Entry("Filters=IsFavorite", "/Artists?Filters=IsFavorite", - func(a *Router) http.HandlerFunc { return a.getArtists }), - Entry("isFavorite=true", "/Artists?isFavorite=true", - func(a *Router) http.HandlerFunc { return a.getArtists }), - Entry("on /Artists/AlbumArtists", "/Artists/AlbumArtists?Filters=IsFavorite", - func(a *Router) http.HandlerFunc { return a.getAlbumArtists }), - ) - - It("404s a malformed ParentId instead of listing every library's artists", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("getGenres", func() { - It("lists genres via /Genres", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Genres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getGenres, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).NotTo(BeNil()) - }) - - It("handles /MusicGenres the same way", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/MusicGenres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getGenres, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - }) - - Describe("getStudios", func() { - It("scopes results to the user's accessible libraries", func() { - tagRepo := ds.Tag().(*tests.MockTagRepo) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) - invoke(api.getStudios, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := tagRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_tag.library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - // An empty scope (admin, or a non-admin with no explicit library grants) must be treated - // as unrestricted, matching accessibleLibraryIDs' documented contract, not as "match nothing". - It("does not restrict results for an admin user", func() { - tagRepo := ds.Tag().(*tests.MockTagRepo) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxAdmin()) - invoke(api.getStudios, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(tagRepo.Options.Filters).To(BeNil()) - }) - - It("404s a malformed ParentId instead of listing every library's studios", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Studios?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getStudios, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("getQueryFiltersLegacy", func() { - It("scopes genres to the user's accessible libraries", func() { - genreRepo := ds.Genre().(*tests.MockedGenreRepo) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) - invoke(api.getQueryFiltersLegacy, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := genreRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_tag.library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("does not restrict genres for an admin user", func() { - genreRepo := ds.Genre().(*tests.MockedGenreRepo) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxAdmin()) - invoke(api.getQueryFiltersLegacy, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(genreRepo.Options.Filters).To(BeNil()) - }) - - It("404s a malformed ParentId instead of listing every library's filters", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/Filters?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getQueryFiltersLegacy, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) -}) diff --git a/server/jellyfin/discovery.go b/server/jellyfin/discovery.go deleted file mode 100644 index d111287a6..000000000 --- a/server/jellyfin/discovery.go +++ /dev/null @@ -1,110 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net" - "strconv" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/publicurl" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/gg" -) - -// Jellyfin clients broadcast this query text to this UDP port. -const ( - discoveryPort = 7359 - discoveryQuery = "who is jellyfinserver?" -) - -type discoveryInfo struct { - Address string `json:"Address"` - Id string `json:"Id"` - Name string `json:"Name"` - EndpointAddress *string `json:"EndpointAddress"` -} - -// Discovery answers LAN auto-discovery broadcasts with the same identity the Router reports. -type Discovery struct { - ds model.DataStore - serverIDVal string -} - -func NewDiscovery(ds model.DataStore) *Discovery { - return &Discovery{ds: ds} -} - -func (d *Discovery) serverID(ctx context.Context) string { - return resolveServerID(ctx, d.ds, &d.serverIDVal) -} - -// Serve runs until ctx is done. A failed bind is only logged: discovery is best-effort. -func (d *Discovery) Serve(ctx context.Context) { - if !hasAdvertisableAddress() { - log.Warn(ctx, "Jellyfin API: auto-discovery is off, a unix socket server needs a BaseURL with a host to advertise") - return - } - // udp4 only: a dual-stack bind can share the port with another server and never get a packet. - conn, err := net.ListenPacket("udp4", net.JoinHostPort("0.0.0.0", strconv.Itoa(discoveryPort))) - if err != nil { - log.Warn(ctx, "Jellyfin API: auto-discovery is off, the UDP port is unavailable. Is another Jellyfin server running?", "port", discoveryPort, err) - return - } - log.Info(ctx, "Jellyfin API: listening for auto-discovery broadcasts", "port", discoveryPort) - d.ServeOn(ctx, conn) -} - -// ServeOn answers discovery queries on conn until ctx is done, then closes conn. -func (d *Discovery) ServeOn(ctx context.Context, conn net.PacketConn) { - defer conn.Close() - stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) - defer stop() - buf := make([]byte, 1024) - for { - n, remote, err := conn.ReadFrom(buf) - if err != nil { - if ctx.Err() == nil { - log.Error(ctx, "Jellyfin API: auto-discovery listener stopped", err) - } - return - } - if !strings.Contains(strings.ToLower(string(buf[:n])), discoveryQuery) { - continue - } - info := discoveryInfo{Address: discoveryAddress(ctx, remote), Id: d.serverID(ctx), Name: serverName()} - res, _ := json.Marshal(info) - log.Debug(ctx, "Jellyfin API: answering auto-discovery request", "from", remote.String(), "address", info.Address) - if _, err := conn.WriteTo(res, remote); err != nil { - log.Debug(ctx, "Jellyfin API: could not answer auto-discovery request", "to", remote.String(), err) - } - } -} - -// Behind a unix socket nothing listens on Port, so only a BaseURL host gives clients an address. -func hasAdvertisableAddress() bool { - return conf.Server.BaseHost != "" || !strings.HasPrefix(conf.Server.Address, "unix:") -} - -func discoveryAddress(ctx context.Context, remote net.Addr) string { - scheme := gg.If(conf.Server.TLSEnabled(), "https", "http") - host := net.JoinHostPort(localIPFor(remote), strconv.Itoa(conf.Server.Port)) - return publicurl.AbsoluteURL(request.WithServerAddress(ctx, scheme, host), consts.URLPathJellyfinAPI, nil) -} - -// On a multi-homed host, only the interface that routes to the requester is reachable by it. -func localIPFor(remote net.Addr) string { - if ip := parseIP(conf.Server.Address); ip.IsValid() && !ip.IsUnspecified() { - return ip.String() - } - c, err := net.Dial("udp", remote.String()) - if err != nil { - return conf.Server.Address - } - defer c.Close() - return c.LocalAddr().(*net.UDPAddr).IP.String() -} diff --git a/server/jellyfin/discovery_test.go b/server/jellyfin/discovery_test.go deleted file mode 100644 index bcd11fe76..000000000 --- a/server/jellyfin/discovery_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "net" - "os" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type failingConn struct { - net.PacketConn - closed bool -} - -func (c *failingConn) ReadFrom([]byte) (int, net.Addr, error) { - return 0, nil, errors.New("read failed") -} - -func (c *failingConn) Close() error { - c.closed = true - return nil -} - -var _ = Describe("Discovery", func() { - var d *Discovery - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ServerName = "Test Server" - conf.Server.Address = "0.0.0.0" - conf.Server.Port = 4533 - conf.Server.BaseHost = "" - conf.Server.BaseScheme = "" - conf.Server.BasePath = "" - conf.Server.TLSCert = "" - conf.Server.TLSKey = "" - d = &Discovery{} - }) - - DescribeTable("discoveryAddress", - func(setup func(), expected string) { - setup() - remote := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 50000} - Expect(discoveryAddress(context.Background(), remote)).To(Equal(expected)) - }, - Entry("uses the BaseURL host and scheme when set", func() { - conf.Server.BaseScheme = "https" - conf.Server.BaseHost = "music.example.com" - conf.Server.BasePath = "/nd" - }, "https://music.example.com/nd/jellyfin"), - Entry("uses a specific bind Address with the Port", func() { - conf.Server.Address = "192.168.1.10" - }, "http://192.168.1.10:4533/jellyfin"), - Entry("falls back to the interface facing the requester when Address is unspecified", func() {}, - "http://127.0.0.1:4533/jellyfin"), - Entry("falls back to the interface facing the requester when Address is empty", func() { - conf.Server.Address = "" - }, "http://127.0.0.1:4533/jellyfin"), - Entry("advertises https when TLS is configured", func() { - conf.Server.TLSCert = "/path/cert.pem" - conf.Server.TLSKey = "/path/key.pem" - }, "https://127.0.0.1:4533/jellyfin"), - Entry("advertises http when only the TLS cert is configured", func() { - conf.Server.TLSCert = "/path/cert.pem" - }, "http://127.0.0.1:4533/jellyfin"), - Entry("keeps a path-only BaseURL as the path prefix", func() { - conf.Server.BasePath = "/music" - }, "http://127.0.0.1:4533/music/jellyfin"), - Entry("does not double the slash when BasePath has a trailing slash", func() { - conf.Server.BasePath = "/music/" - }, "http://127.0.0.1:4533/music/jellyfin"), - ) - - DescribeTable("hasAdvertisableAddress", - func(address, baseHost string, expected bool) { - conf.Server.Address = address - conf.Server.BaseHost = baseHost - Expect(hasAdvertisableAddress()).To(Equal(expected)) - }, - Entry("TCP listener", "0.0.0.0", "", true), - Entry("unix socket without a BaseURL host", "unix:/tmp/navidrome.sock", "", false), - Entry("unix socket behind a proxy named by BaseURL", "unix:/tmp/navidrome.sock", "music.example.com", true), - ) - - It("closes the connection when the read loop fails", func() { - fake := &failingConn{} - d.ServeOn(context.Background(), fake) - Expect(fake.closed).To(BeTrue()) - }) - - Describe("ServeOn", func() { - var ( - server net.PacketConn - client net.PacketConn - cancel context.CancelFunc - done chan struct{} - ) - - BeforeEach(func() { - var err error - server, err = net.ListenPacket("udp4", "127.0.0.1:0") - Expect(err).ToNot(HaveOccurred()) - client, err = net.ListenPacket("udp4", "127.0.0.1:0") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(client.Close) - - var ctx context.Context - ctx, cancel = context.WithCancel(context.Background()) - done = make(chan struct{}) - go func() { - defer close(done) - d.ServeOn(ctx, server) - }() - DeferCleanup(func() { - cancel() - Eventually(done).Should(BeClosed()) - }) - }) - - send := func(msg string) { - _, err := client.WriteTo([]byte(msg), server.LocalAddr()) - Expect(err).ToNot(HaveOccurred()) - } - receive := func() ([]byte, error) { - Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed()) - buf := make([]byte, 1024) - n, _, err := client.ReadFrom(buf) - return buf[:n], err - } - - It("answers the discovery query with the server identity", func() { - send("who is JellyfinServer?") - res, err := receive() - Expect(err).ToNot(HaveOccurred()) - - var info discoveryInfo - Expect(json.Unmarshal(res, &info)).To(Succeed()) - Expect(info.Address).To(Equal("http://127.0.0.1:4533/jellyfin")) - Expect(info.Id).To(Equal(d.serverID(context.Background()))) - Expect(info.Name).To(Equal("Test Server")) - Expect(string(res)).To(ContainSubstring(`"EndpointAddress":null`)) - }) - - It("matches the query case-insensitively", func() { - send("WHO IS JELLYFINSERVER?") - _, err := receive() - Expect(err).ToNot(HaveOccurred()) - }) - - // The loop is serial, so a reply to the first packet would arrive before the second's. - It("ignores unrelated packets", func() { - send("who is PlexServer?") - send("who is JellyfinServer?") - _, err := receive() - Expect(err).ToNot(HaveOccurred()) - Expect(client.SetReadDeadline(time.Now().Add(50 * time.Millisecond))).To(Succeed()) - _, _, err = client.ReadFrom(make([]byte, 1024)) - Expect(errors.Is(err, os.ErrDeadlineExceeded)).To(BeTrue()) - }) - - It("stops and closes the socket when the context is cancelled", func() { - cancel() - Eventually(done).Should(BeClosed()) - _, _, err := server.ReadFrom(make([]byte, 1)) - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go deleted file mode 100644 index 4a28eb313..000000000 --- a/server/jellyfin/dto/dto.go +++ /dev/null @@ -1,349 +0,0 @@ -package dto - -// PublicSystemInfo is the unauthenticated handshake payload (GET /System/Info/Public). -type PublicSystemInfo struct { - LocalAddress string `json:"LocalAddress,omitempty"` - ServerName string `json:"ServerName"` - Version string `json:"Version"` - ProductName string `json:"ProductName"` - OperatingSystem string `json:"OperatingSystem,omitempty"` - Id string `json:"Id"` - StartupWizardCompleted bool `json:"StartupWizardCompleted"` -} - -// SystemInfo is the authenticated variant (GET /System/Info). -type SystemInfo struct { - PublicSystemInfo - HasPendingRestart bool `json:"HasPendingRestart"` - IsShuttingDown bool `json:"IsShuttingDown"` - SupportsLibraryMonitor bool `json:"SupportsLibraryMonitor"` - CachePath string `json:"CachePath,omitempty"` -} - -// EndPointInfo describes the caller's network location (GET /System/Endpoint). -type EndPointInfo struct { - IsLocal bool `json:"IsLocal"` - IsInNetwork bool `json:"IsInNetwork"` -} - -type NameGuidPair struct { - Name string `json:"Name"` - Id string `json:"Id"` -} - -type UserItemDataDto struct { - Rating *float64 `json:"Rating,omitempty"` - PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` - PlayCount int `json:"PlayCount"` - IsFavorite bool `json:"IsFavorite"` - Played bool `json:"Played"` - Key string `json:"Key"` - ItemId string `json:"ItemId,omitempty"` - LastPlayedDate *string `json:"LastPlayedDate,omitempty"` -} - -type BaseItemDto struct { - Name string `json:"Name"` - ServerId string `json:"ServerId,omitempty"` - Id string `json:"Id"` - // PlaylistItemId identifies an entry within a playlist listing (GET /Playlists/{id}/Items), - // distinct from Id so a song appearing more than once can be removed by occurrence - // (DELETE .../Items?EntryIds=...) rather than by song id. - PlaylistItemId string `json:"PlaylistItemId,omitempty"` - Type string `json:"Type"` - IsFolder bool `json:"IsFolder"` - MediaType string `json:"MediaType,omitempty"` - CollectionType string `json:"CollectionType,omitempty"` - LocationType string `json:"LocationType,omitempty"` - HasLyrics *bool `json:"HasLyrics,omitempty"` - // ChannelId is always null for music, but Jellyfin emits it on every item and clients may require it. - ChannelId *string `json:"ChannelId"` - Tags []string `json:"Tags,omitzero"` - SortName string `json:"SortName,omitempty"` - Path string `json:"Path,omitempty"` - ParentId string `json:"ParentId,omitempty"` - RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` - IndexNumber *int `json:"IndexNumber,omitempty"` - ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"` - ProductionYear *int `json:"ProductionYear,omitempty"` - // PremiereDate is the ISO 8601 release date; Finamp sorts "Latest Releases" by it client-side. - PremiereDate *string `json:"PremiereDate,omitempty"` - // DateCreated is the ISO 8601 date the item was added to the library; clients show it as - // "Date Added" and sort "Recently Added" by it. - DateCreated string `json:"DateCreated,omitempty"` - Album string `json:"Album,omitempty"` - AlbumId string `json:"AlbumId,omitempty"` - AlbumArtist string `json:"AlbumArtist,omitempty"` - AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` - AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` - Artists []string `json:"Artists,omitzero"` - ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` - Genres []string `json:"Genres,omitzero"` - GenreItems []NameGuidPair `json:"GenreItems,omitzero"` - Studios []NameGuidPair `json:"Studios,omitempty"` - NormalizationGain *float64 `json:"NormalizationGain,omitempty"` - AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` - ChildCount *int `json:"ChildCount,omitempty"` - SongCount *int `json:"SongCount,omitempty"` - AlbumCount *int `json:"AlbumCount,omitempty"` - ImageTags map[string]string `json:"ImageTags"` - // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a - // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't - // calculating blurhashes. - ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` - // PrimaryImageAspectRatio is width/height of the Primary image, attached only when the request's - // Fields asks for it; omitted rather than guessed, since a wrong ratio mis-shapes a placeholder. - PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio,omitempty"` - BackdropImageTags []string `json:"BackdropImageTags"` - UserData *UserItemDataDto `json:"UserData,omitempty"` - MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` - Container string `json:"Container,omitempty"` - CanDownload bool `json:"CanDownload"` -} - -// PlaylistUserPermissions is the response shape for GET /Playlists/{id}/Users(/{userId}), which -// Finamp probes before allowing playlist edits. -type PlaylistUserPermissions struct { - UserId string `json:"UserId"` - CanEdit bool `json:"CanEdit"` -} - -// PlaylistInfo is the response shape for GET /Playlists/{id}. ItemIds are media item ids, not -// playlist-entry ids (matching real Jellyfin); Finamp reads OpenAccess for the public-visibility toggle. -type PlaylistInfo struct { - OpenAccess bool `json:"OpenAccess"` - Shares []PlaylistUserPermissions `json:"Shares"` - ItemIds []string `json:"ItemIds"` -} - -type QueryResult struct { - Items []BaseItemDto `json:"Items"` - TotalRecordCount int `json:"TotalRecordCount"` - StartIndex int `json:"StartIndex"` -} - -type UserDto struct { - Name string `json:"Name"` - ServerId string `json:"ServerId,omitempty"` - ServerName string `json:"ServerName,omitempty"` - Id string `json:"Id"` - HasPassword bool `json:"HasPassword"` - HasConfiguredPassword bool `json:"HasConfiguredPassword"` - HasConfiguredEasyPassword bool `json:"HasConfiguredEasyPassword"` - PrimaryImageTag string `json:"PrimaryImageTag,omitempty"` - Policy *UserPolicy `json:"Policy,omitempty"` - Configuration *UserConfiguration `json:"Configuration,omitempty"` -} - -// UserPolicy mirrors real Jellyfin's User.Policy. Finamp reads it right after login and crashes if -// it's absent, so every field must be present even though Navidrome lacks most of these concepts. -type UserPolicy struct { - IsAdministrator bool `json:"IsAdministrator"` - IsHidden bool `json:"IsHidden"` - EnableCollectionManagement bool `json:"EnableCollectionManagement"` - EnableSubtitleManagement bool `json:"EnableSubtitleManagement"` - EnableLyricManagement bool `json:"EnableLyricManagement"` - IsDisabled bool `json:"IsDisabled"` - BlockedTags []string `json:"BlockedTags"` - AllowedTags []string `json:"AllowedTags"` - EnableUserPreferenceAccess bool `json:"EnableUserPreferenceAccess"` - AccessSchedules []string `json:"AccessSchedules"` - BlockUnratedItems []string `json:"BlockUnratedItems"` - EnableRemoteControlOfOtherUsers bool `json:"EnableRemoteControlOfOtherUsers"` - EnableSharedDeviceControl bool `json:"EnableSharedDeviceControl"` - EnableRemoteAccess bool `json:"EnableRemoteAccess"` - EnableLiveTvManagement bool `json:"EnableLiveTvManagement"` - EnableLiveTvAccess bool `json:"EnableLiveTvAccess"` - EnableMediaPlayback bool `json:"EnableMediaPlayback"` - EnableAudioPlaybackTranscoding bool `json:"EnableAudioPlaybackTranscoding"` - EnableVideoPlaybackTranscoding bool `json:"EnableVideoPlaybackTranscoding"` - EnablePlaybackRemuxing bool `json:"EnablePlaybackRemuxing"` - ForceRemoteSourceTranscoding bool `json:"ForceRemoteSourceTranscoding"` - EnableContentDeletion bool `json:"EnableContentDeletion"` - EnableContentDeletionFromFolders []string `json:"EnableContentDeletionFromFolders"` - EnableContentDownloading bool `json:"EnableContentDownloading"` - EnableSyncTranscoding bool `json:"EnableSyncTranscoding"` - EnableMediaConversion bool `json:"EnableMediaConversion"` - EnabledDevices []string `json:"EnabledDevices"` - EnableAllDevices bool `json:"EnableAllDevices"` - EnabledChannels []string `json:"EnabledChannels"` - EnableAllChannels bool `json:"EnableAllChannels"` - EnabledFolders []string `json:"EnabledFolders"` - EnableAllFolders bool `json:"EnableAllFolders"` - InvalidLoginAttemptCount int `json:"InvalidLoginAttemptCount"` - LoginAttemptsBeforeLockout int `json:"LoginAttemptsBeforeLockout"` - MaxActiveSessions int `json:"MaxActiveSessions"` - EnablePublicSharing bool `json:"EnablePublicSharing"` - BlockedMediaFolders []string `json:"BlockedMediaFolders"` - BlockedChannels []string `json:"BlockedChannels"` - RemoteClientBitrateLimit int `json:"RemoteClientBitrateLimit"` - AuthenticationProviderId string `json:"AuthenticationProviderId"` - PasswordResetProviderId string `json:"PasswordResetProviderId"` - SyncPlayAccess string `json:"SyncPlayAccess"` -} - -// UserConfiguration mirrors real Jellyfin's User.Configuration. Like UserPolicy, clients expect it -// always present, even though most settings don't apply to Navidrome's audio-only library. -type UserConfiguration struct { - PlayDefaultAudioTrack bool `json:"PlayDefaultAudioTrack"` - SubtitleLanguagePreference string `json:"SubtitleLanguagePreference"` - DisplayMissingEpisodes bool `json:"DisplayMissingEpisodes"` - GroupedFolders []string `json:"GroupedFolders"` - SubtitleMode string `json:"SubtitleMode"` - DisplayCollectionsView bool `json:"DisplayCollectionsView"` - EnableLocalPassword bool `json:"EnableLocalPassword"` - OrderedViews []string `json:"OrderedViews"` - LatestItemsExcludes []string `json:"LatestItemsExcludes"` - MyMediaExcludes []string `json:"MyMediaExcludes"` - HidePlayedInLatest bool `json:"HidePlayedInLatest"` - RememberAudioSelections bool `json:"RememberAudioSelections"` - RememberSubtitleSelections bool `json:"RememberSubtitleSelections"` - EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"` - CastReceiverId string `json:"CastReceiverId"` -} - -// SessionInfo mirrors real Jellyfin's SessionInfoDto. JellyBox requires Id and PlayState, and Finamp -// requires UserId, LastActivityDate and the bools, so none of those may be omitted. -type SessionInfo struct { - Id string `json:"Id"` - UserId string `json:"UserId"` - UserName string `json:"UserName"` - Client string `json:"Client"` - DeviceId string `json:"DeviceId"` - DeviceName string `json:"DeviceName"` - ApplicationVersion string `json:"ApplicationVersion"` - ServerId string `json:"ServerId"` - LastActivityDate string `json:"LastActivityDate"` - IsActive bool `json:"IsActive"` - SupportsMediaControl bool `json:"SupportsMediaControl"` - SupportsRemoteControl bool `json:"SupportsRemoteControl"` - HasCustomDeviceName bool `json:"HasCustomDeviceName"` - PlayableMediaTypes []string `json:"PlayableMediaTypes"` - SupportedCommands []string `json:"SupportedCommands"` - AdditionalUsers []any `json:"AdditionalUsers"` - NowPlayingQueue []any `json:"NowPlayingQueue"` - PlayState PlayerStateInfo `json:"PlayState"` -} - -type PlayerStateInfo struct { - CanSeek bool `json:"CanSeek"` - IsPaused bool `json:"IsPaused"` - IsMuted bool `json:"IsMuted"` - RepeatMode string `json:"RepeatMode"` - PlaybackOrder string `json:"PlaybackOrder"` -} - -type QuickConnectResult struct { - Authenticated bool `json:"Authenticated"` - Secret string `json:"Secret"` - Code string `json:"Code"` - DeviceId string `json:"DeviceId"` - DeviceName string `json:"DeviceName"` - AppName string `json:"AppName"` - AppVersion string `json:"AppVersion"` - DateAdded string `json:"DateAdded"` -} - -type AuthenticationResult struct { - User *UserDto `json:"User"` - SessionInfo *SessionInfo `json:"SessionInfo"` - AccessToken string `json:"AccessToken"` - ServerId string `json:"ServerId"` -} - -// MediaStream mirrors real Jellyfin's MediaStream. Finamp declares several bools as non-nullable, so -// they must always be emitted (no omitempty). Finamp also does MediaStreams.firstWhere((s) => s.type -// == 'Audio'), so MediaSourceInfo must include at least one Audio stream or that lookup throws. -type MediaStream struct { - Codec string `json:"Codec,omitempty"` - Type string `json:"Type"` - Index int `json:"Index"` - BitRate int `json:"BitRate,omitempty"` - Channels int `json:"Channels,omitempty"` - SampleRate int `json:"SampleRate,omitempty"` - ChannelLayout string `json:"ChannelLayout,omitempty"` - IsInterlaced bool `json:"IsInterlaced"` - IsDefault bool `json:"IsDefault"` - IsForced bool `json:"IsForced"` - IsExternal bool `json:"IsExternal"` - IsTextSubtitleStream bool `json:"IsTextSubtitleStream"` - SupportsExternalStream bool `json:"SupportsExternalStream"` -} - -// MediaSourceInfo mirrors real Jellyfin's MediaSourceInfo. Finamp declares several bools/arrays as -// non-nullable, so a missing field deserializes to null and throws a cast error that aborts parsing -// of the whole item list; emit them always (no omitempty on bools). -type MediaSourceInfo struct { - Id string `json:"Id"` - Path string `json:"Path,omitempty"` - Protocol string `json:"Protocol"` - Container string `json:"Container,omitempty"` - TranscodingUrl string `json:"TranscodingUrl,omitempty"` - TranscodingSubProtocol string `json:"TranscodingSubProtocol,omitempty"` - Size int64 `json:"Size,omitempty"` - Name string `json:"Name,omitempty"` - IsRemote bool `json:"IsRemote"` - RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` - Bitrate int `json:"Bitrate,omitempty"` - SupportsTranscoding bool `json:"SupportsTranscoding"` - SupportsDirectStream bool `json:"SupportsDirectStream"` - SupportsDirectPlay bool `json:"SupportsDirectPlay"` - Type string `json:"Type"` - ReadAtNativeFramerate bool `json:"ReadAtNativeFramerate"` - IgnoreDts bool `json:"IgnoreDts"` - IgnoreIndex bool `json:"IgnoreIndex"` - GenPtsInput bool `json:"GenPtsInput"` - IsInfiniteStream bool `json:"IsInfiniteStream"` - UseMostCompatibleTranscodingProfile bool `json:"UseMostCompatibleTranscodingProfile"` - RequiresOpening bool `json:"RequiresOpening"` - RequiresClosing bool `json:"RequiresClosing"` - RequiresLooping bool `json:"RequiresLooping"` - SupportsProbing bool `json:"SupportsProbing"` - HasSegments bool `json:"HasSegments"` - MediaStreams []MediaStream `json:"MediaStreams"` - MediaAttachments []any `json:"MediaAttachments"` - Formats []string `json:"Formats"` -} - -type PlaybackInfoResponse struct { - MediaSources []MediaSourceInfo `json:"MediaSources"` - PlaySessionId string `json:"PlaySessionId"` -} - -// LyricDto mirrors Jellyfin's GET /Audio/{itemId}/Lyrics response. Feishin and Jellify read only -// Lyrics[].Text and Start (ticks); Metadata is for completeness/Finamp. -type LyricDto struct { - Metadata LyricMetadata `json:"Metadata"` - Lyrics []LyricLine `json:"Lyrics"` -} - -type LyricMetadata struct { - Artist string `json:"Artist,omitempty"` - Album string `json:"Album,omitempty"` - Title string `json:"Title,omitempty"` - Length int64 `json:"Length,omitempty"` - Offset *int64 `json:"Offset,omitempty"` - IsSynced bool `json:"IsSynced"` -} - -type LyricLine struct { - Text string `json:"Text"` - Start *int64 `json:"Start,omitempty"` - Cues []LyricLineCue `json:"Cues,omitempty"` -} - -type LyricLineCue struct { - Position int `json:"Position"` - EndPosition int `json:"EndPosition"` - Start int64 `json:"Start"` - End *int64 `json:"End,omitempty"` -} - -// QueryFiltersLegacy is the response for GET /Items/Filters. All four lists are always present; -// clients (jellyfin-web) render each unconditionally. -type QueryFiltersLegacy struct { - Genres []string `json:"Genres"` - Tags []string `json:"Tags"` - OfficialRatings []string `json:"OfficialRatings"` - Years []int `json:"Years"` -} diff --git a/server/jellyfin/dto/dto_suite_test.go b/server/jellyfin/dto/dto_suite_test.go deleted file mode 100644 index a4eebb0d3..000000000 --- a/server/jellyfin/dto/dto_suite_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package dto - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestDto(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Jellyfin DTO Suite") -} - -// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same -// id shape production uses. -func testID(label string) string { return id.NewHash("jellyfin-test", label) } diff --git a/server/jellyfin/dto/fields.go b/server/jellyfin/dto/fields.go deleted file mode 100644 index fb49e43c7..000000000 --- a/server/jellyfin/dto/fields.go +++ /dev/null @@ -1,27 +0,0 @@ -package dto - -import "strings" - -// Fields is the parsed set of a Jellyfin request's Fields param (lowercased). It controls which -// conditional fields a mapped item carries — chiefly MediaSources — matching real Jellyfin, which -// omits those unless the client asks for them. -type Fields map[string]struct{} - -// ParseFields builds a lowercased set from the Fields param. It accepts each value comma-separated -// (Fields=a,b) and across repeated params (Fields=a&Fields=b), both of which real Jellyfin honors. -func ParseFields(values ...string) Fields { - f := Fields{} - for _, csv := range values { - for name := range strings.SplitSeq(csv, ",") { - if name = strings.TrimSpace(strings.ToLower(name)); name != "" { - f[name] = struct{}{} - } - } - } - return f -} - -func (f Fields) Has(name string) bool { - _, ok := f[strings.ToLower(name)] - return ok -} diff --git a/server/jellyfin/dto/fields_test.go b/server/jellyfin/dto/fields_test.go deleted file mode 100644 index 59d06734d..000000000 --- a/server/jellyfin/dto/fields_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package dto - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ParseFields", func() { - It("parses a single comma-separated value", func() { - f := ParseFields("Genres,MediaSources") - Expect(f.Has("Genres")).To(BeTrue()) - Expect(f.Has("MediaSources")).To(BeTrue()) - }) - - It("parses fields spread across repeated params", func() { - f := ParseFields("Genres", "MediaSources", "SortName") - Expect(f.Has("Genres")).To(BeTrue()) - Expect(f.Has("MediaSources")).To(BeTrue()) - Expect(f.Has("SortName")).To(BeTrue()) - }) - - It("returns an empty set for no values", func() { - Expect(ParseFields()).To(BeEmpty()) - Expect(ParseFields("")).To(BeEmpty()) - }) -}) diff --git a/server/jellyfin/dto/filters_test.go b/server/jellyfin/dto/filters_test.go deleted file mode 100644 index ad3367264..000000000 --- a/server/jellyfin/dto/filters_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package dto - -import ( - "encoding/json" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("QueryFiltersLegacy", func() { - It("marshals all four keys, empty ones as [] not null", func() { - b, err := json.Marshal(QueryFiltersLegacy{ - Genres: []string{"Rock"}, Tags: []string{}, OfficialRatings: []string{}, Years: []int{1999}, - }) - Expect(err).ToNot(HaveOccurred()) - j := string(b) - Expect(j).To(ContainSubstring(`"Genres":["Rock"]`)) - Expect(j).To(ContainSubstring(`"Tags":[]`)) - Expect(j).To(ContainSubstring(`"OfficialRatings":[]`)) - Expect(j).To(ContainSubstring(`"Years":[1999]`)) - }) -}) diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go deleted file mode 100644 index 2c8b5317b..000000000 --- a/server/jellyfin/dto/ids.go +++ /dev/null @@ -1,143 +0,0 @@ -package dto - -import ( - "bytes" - "encoding/hex" - "strconv" - "strings" - - "github.com/navidrome/navidrome/model/id" -) - -// guidLen is the length of a Jellyfin GUID on the wire: 16 bytes as lowercase hex, no dashes -// (what Guid.ToString("N") produces). -const guidLen = 32 - -// Reserved GUIDs stand in for ids that aren't 128-bit values: 12 zero bytes, a non-zero kind tag, -// then a 24-bit payload. The tag is never zero because Jellyfin serializes the all-zero GUID as null. -const ( - kindIdx = 12 - maxPayload = 1<<24 - 1 -) - -const ( - kindLibrary byte = iota + 1 - kindPlaylistsFolder - kindPlaylistEntry -) - -var zeroPrefix [kindIdx]byte - -// PlaylistsFolderID is the internal id of the synthetic "playlists library" folder. It can't be -// mistaken for a real id, which is always 22-char base62. -const PlaylistsFolderID = "playlists" - -// PlaylistsFolderGUID is the wire form of PlaylistsFolderID. -var PlaylistsFolderGUID = encodeReserved(kindPlaylistsFolder, 0) - -// EncodeID renders a canonical Navidrome id as a Jellyfin GUID. Anything that isn't one encodes -// to "" rather than to a shape clients can't parse. -func EncodeID(ndID string) string { - b, err := id.Decode(ndID) - if err != nil { - return "" - } - return hex.EncodeToString(b) -} - -// EncodeLibraryID renders a library's integer id in the reserved GUID space. -func EncodeLibraryID(libID int) string { - return encodeReserved(kindLibrary, libID) -} - -// EncodePlaylistEntryID renders a playlist entry's position (model.PlaylistTrack.ID, an integer -// column) in the reserved GUID space. Clients echo it back to remove one occurrence of a song. -func EncodePlaylistEntryID(entryID string) string { - n, err := strconv.Atoi(entryID) - if err != nil { - return "" - } - return encodeReserved(kindPlaylistEntry, n) -} - -func encodeReserved(kind byte, payload int) string { - if payload < 0 || payload > maxPayload { - return "" - } - var b [16]byte - b[kindIdx] = kind - b[13], b[14], b[15] = byte(payload>>16), byte(payload>>8), byte(payload) - return hex.EncodeToString(b[:]) -} - -// DecodeID maps an inbound GUID back to the identifier the rest of the API uses: a canonical id, -// a decimal library id, or PlaylistsFolderID. ok is false for anything that isn't a well-formed -// GUID — including the empty string — so an undecodable id can't reach a caller as "no filter". -// Dashed and uppercase forms are accepted, as Jellyfin's Guid.Parse accepts them. Playlist entries -// decode through DecodePlaylistEntryID instead, so a position can't reach a caller expecting an -// entity id. -func DecodeID(guid string) (string, bool) { - b, ok := decodeGUID(guid) - if !ok { - return "", false - } - kind, payload, reserved := reservedFields(b) - if !reserved { - return id.Encode(b), true - } - switch kind { - case kindLibrary: - return strconv.Itoa(payload), true - case kindPlaylistsFolder: - return PlaylistsFolderID, true - } - return "", false -} - -// DecodeIDs decodes a list of GUIDs, all-or-nothing: ok is false if any entry is malformed, so a -// caller can't mistake "every entry failed" for "no filter" (see DecodeID). -func DecodeIDs(guids []string) ([]string, bool) { - out := make([]string, len(guids)) - for i, guid := range guids { - decoded, ok := DecodeID(guid) - if !ok { - return nil, false - } - out[i] = decoded - } - return out, true -} - -// DecodePlaylistEntryID decodes a playlist entry GUID to its position. It rejects every other kind, -// so an entity id can't be taken for a playlist_tracks row. -func DecodePlaylistEntryID(guid string) (string, bool) { - b, ok := decodeGUID(guid) - if !ok { - return "", false - } - if kind, payload, reserved := reservedFields(b); reserved && kind == kindPlaylistEntry { - return strconv.Itoa(payload), true - } - return "", false -} - -func decodeGUID(guid string) ([16]byte, bool) { - guid = strings.ToLower(strings.ReplaceAll(guid, "-", "")) - if len(guid) != guidLen { - return [16]byte{}, false - } - bs, err := hex.DecodeString(guid) - if err != nil { - return [16]byte{}, false - } - return [16]byte(bs), true -} - -// reservedFields reports the kind tag and payload of a reserved GUID; reserved is false for the -// entity GUIDs that make up almost all traffic. -func reservedFields(b [16]byte) (kind byte, payload int, reserved bool) { - if !bytes.Equal(b[:kindIdx], zeroPrefix[:]) { - return 0, 0, false - } - return b[kindIdx], int(b[13])<<16 | int(b[14])<<8 | int(b[15]), true -} diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go deleted file mode 100644 index c95dbd790..000000000 --- a/server/jellyfin/dto/ids_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package dto - -import ( - "encoding/hex" - "strconv" - "strings" - - "github.com/navidrome/navidrome/model/id" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("id codec", func() { - Describe("canonical ids", func() { - It("round-trips a random id", func() { - ndID := id.NewRandom() - decoded, ok := DecodeID(EncodeID(ndID)) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(ndID)) - }) - - It("round-trips a hash id", func() { - ndID := id.NewHash("artist", "Weird Al") - decoded, ok := DecodeID(EncodeID(ndID)) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(ndID)) - }) - - It("emits exactly 32 lowercase hex chars", func() { - for range 20 { - Expect(EncodeID(id.NewRandom())).To(MatchRegexp("^[0-9a-f]{32}$")) - } - }) - - It("accepts a dashed GUID, matching Jellyfin's Guid.Parse", func() { - ndID := id.NewRandom() - guid := EncodeID(ndID) - dashed := guid[0:8] + "-" + guid[8:12] + "-" + guid[12:16] + "-" + guid[16:20] + "-" + guid[20:32] - decoded, ok := DecodeID(dashed) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(ndID)) - }) - - It("accepts an uppercase GUID", func() { - ndID := id.NewRandom() - decoded, ok := DecodeID(strings.ToUpper(EncodeID(ndID))) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(ndID)) - }) - }) - - Describe("reserved space", func() { - DescribeTable("round-trips library ids", - func(libID int, guid string) { - Expect(EncodeLibraryID(libID)).To(Equal(guid)) - decoded, ok := DecodeID(guid) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(strconv.Itoa(libID))) - }, - Entry("first library", 1, "00000000000000000000000001000001"), - Entry("double digit", 42, "0000000000000000000000000100002a"), - Entry("max payload", 0xffffff, "00000000000000000000000001ffffff"), - ) - - It("round-trips the playlists folder", func() { - Expect(PlaylistsFolderGUID).To(Equal("00000000000000000000000002000000")) - decoded, ok := DecodeID(PlaylistsFolderGUID) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(PlaylistsFolderID)) - }) - - DescribeTable("round-trips playlist entry positions", - func(entryID, guid string) { - Expect(EncodePlaylistEntryID(entryID)).To(Equal(guid)) - decoded, ok := DecodePlaylistEntryID(guid) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(entryID)) - }, - Entry("first entry", "1", "00000000000000000000000003000001"), - Entry("later entry", "300", "0000000000000000000000000300012c"), - ) - - It("rejects a non-integer playlist entry id", func() { - Expect(EncodePlaylistEntryID("s1")).To(Equal("")) - Expect(EncodePlaylistEntryID("")).To(Equal("")) - Expect(EncodePlaylistEntryID("-1")).To(Equal("")) - }) - - It("rejects a payload wider than the reserved 24 bits", func() { - Expect(EncodeLibraryID(1 << 24)).To(Equal("")) - Expect(EncodeLibraryID(-1)).To(Equal("")) - Expect(EncodePlaylistEntryID("16777216")).To(Equal("")) - }) - - It("keeps library and playlist-entry GUIDs distinct for the same number", func() { - Expect(EncodeLibraryID(3)).ToNot(Equal(EncodePlaylistEntryID("3"))) - }) - - It("does not let one reserved kind decode as another", func() { - _, ok := DecodeID(EncodePlaylistEntryID("3")) - Expect(ok).To(BeFalse()) - _, ok = DecodePlaylistEntryID(EncodeLibraryID(3)) - Expect(ok).To(BeFalse()) - _, ok = DecodePlaylistEntryID(PlaylistsFolderGUID) - Expect(ok).To(BeFalse()) - }) - - It("rejects an unknown kind tag", func() { - _, ok := DecodeID("000000000000000000000000ff000000") - Expect(ok).To(BeFalse()) - }) - - It("never emits the all-zero GUID, which Jellyfin serializes as null", func() { - Expect(EncodeLibraryID(0)).ToNot(Equal("00000000000000000000000000000000")) - Expect(PlaylistsFolderGUID).ToNot(Equal("00000000000000000000000000000000")) - }) - }) - - Describe("malformed input", func() { - DescribeTable("DecodeID reports ok=false", - func(input string) { - decoded, ok := DecodeID(input) - Expect(ok).To(BeFalse()) - Expect(decoded).To(BeEmpty()) - }, - Entry("empty", ""), - Entry("too short", "abc123"), - Entry("too long", "000000000000000000000000010000011"), - Entry("non-hex chars", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"), - Entry("a raw base62 id", id.NewRandom()), - Entry("the old 44-char hex format", hex.EncodeToString([]byte("5QFKvMsJrd57QE2Le2dKKo"))), - ) - - It("encodes a non-canonical id to the empty string", func() { - Expect(EncodeID("")).To(Equal("")) - Expect(EncodeID("playlists")).To(Equal("")) - Expect(EncodeID("42")).To(Equal("")) - }) - }) - - Describe("DecodeIDs", func() { - It("decodes every entry when all are well-formed", func() { - a, b := id.NewRandom(), id.NewRandom() - decoded, ok := DecodeIDs([]string{EncodeID(a), EncodeID(b)}) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal([]string{a, b})) - }) - - It("is all-or-nothing: one malformed entry fails the whole list", func() { - decoded, ok := DecodeIDs([]string{EncodeID(id.NewRandom()), "not-a-guid"}) - Expect(ok).To(BeFalse()) - Expect(decoded).To(BeNil()) - }) - - It("succeeds on an empty list", func() { - decoded, ok := DecodeIDs(nil) - Expect(ok).To(BeTrue()) - Expect(decoded).To(BeEmpty()) - }) - }) -}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go deleted file mode 100644 index fc7e329de..000000000 --- a/server/jellyfin/dto/mappers.go +++ /dev/null @@ -1,471 +0,0 @@ -package dto - -import ( - "cmp" - "fmt" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" -) - -// sortName must match the persistence ORDER BY key (see setSortMappings): Finamp's A-Z jump -// scans SortName client-side, and any mismatch with the server's sort order scrolls to the top. -func sortName(sortTag, orderName, displayName string) string { - if conf.Server.PreferSortTags { - return cmp.Or(sortTag, orderName, displayName) - } - return cmp.Or(orderName, displayName) -} - -// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond. -const ticksPerMillis = 10_000 - -func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1000 * ticksPerMillis) } - -// TicksFromMillis converts milliseconds (Navidrome lyric timestamps) to ticks. -func TicksFromMillis(ms int64) int64 { return ms * ticksPerMillis } - -// MillisFromTicks converts ticks (client-reported playback positions) to milliseconds. -func MillisFromTicks(ticks int64) int64 { return ticks / ticksPerMillis } - -// premiereDate converts a possibly partial date tag ("2007", "2007-02") into the ISO 8601 -// PremiereDate clients parse, falling back to year; nil when neither exists. -func premiereDate(date string, year int) *string { - d := date - switch len(d) { - case 4: - d += "-01-01" - case 7: - d += "-01" - case 10: // already yyyy-mm-dd - default: - if year <= 0 { - return nil - } - d = fmt.Sprintf("%04d-01-01", year) - } - parsed, err := time.Parse(time.DateOnly, d) - if err != nil { - return nil - } - s := JellyfinDate(&parsed) - return &s -} - -// Dates use .NET's round-trip layout, 7 fractional digits and all: Manet rejects plain RFC3339. -const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00" - -// JellyfinDate formats t as the date string clients expect, or "" for the zero time so the -// field is omitted rather than sent as a meaningless epoch. -func JellyfinDate(t *time.Time) string { - if t == nil || t.IsZero() { - return "" - } - return t.UTC().Format(jellyfinDateLayout) -} - -// channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream. -func channelLayout(n int) string { - switch n { - case 1: - return "mono" - case 2: - return "stereo" - case 6: - return "5.1" - case 8: - return "7.1" - default: - return "" - } -} - -// MediaSourceFromMediaFile builds the MediaSourceInfo for direct playback of mf's source file. -// Shared by SongToBaseItem and getPlaybackInfo so Size/Bitrate match across browse and /PlaybackInfo -// responses (Finamp's download dialog reads MediaSources[0].Size from the browse response). -func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { - streams := make([]MediaStream, 1, 2) - streams[0] = MediaStream{ - Type: "Audio", - Index: 0, - Codec: mf.Codec, - BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps. - Channels: mf.Channels, - SampleRate: mf.SampleRate, - ChannelLayout: channelLayout(mf.Channels), - } - // Finamp gates its lyrics view on a Lyric stream in PlaybackInfo, not on HasLyrics. - if mf.HasEmbeddedLyrics() { - streams = append(streams, MediaStream{Type: "Lyric", Index: 1, IsExternal: true}) - } - return MediaSourceInfo{ - Id: EncodeID(mf.ID), - Protocol: "Http", - Container: mf.Suffix, - Size: mf.Size, - Name: mf.Title, - Type: "Default", - RunTimeTicks: TicksFromSeconds(mf.Duration), - Bitrate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's Bitrate is bps. - SupportsDirectPlay: true, - SupportsDirectStream: true, - SupportsTranscoding: true, - IsRemote: false, - SupportsProbing: true, - MediaStreams: streams, - MediaAttachments: []any{}, - Formats: []string{}, - } -} - -func UserData(a model.Annotations, itemID string) *UserItemDataDto { - // Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto. - encodedID := EncodeID(itemID) - d := &UserItemDataDto{ - PlayCount: int(a.PlayCount), - IsFavorite: a.Starred, - Played: a.PlayCount > 0, - Key: encodedID, - ItemId: encodedID, - } - if a.Rating > 0 { - r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 - d.Rating = &r - } - if s := JellyfinDate(a.PlayDate); s != "" { - d.LastPlayedDate = &s - } - return d -} - -// SongToBaseItem maps a media file to an Audio BaseItemDto. MediaSources and SortName are attached -// only when the request's Fields asks for them, mirroring real Jellyfin (which omits both from a -// plain list response); a nil fields set means neither. -func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { - albumID := EncodeID(mf.AlbumID) - item := BaseItemDto{ - Name: mf.Title, - Id: EncodeID(mf.ID), - Type: "Audio", - MediaType: "Audio", - IsFolder: false, - LocationType: "FileSystem", - HasLyrics: new(mf.HasEmbeddedLyrics()), - ParentId: albumID, - Album: mf.Album, - AlbumId: albumID, - AlbumArtist: mf.AlbumArtist, - RunTimeTicks: TicksFromSeconds(mf.Duration), - DateCreated: JellyfinDate(&mf.CreatedAt), - Container: mf.Suffix, - CanDownload: true, - BackdropImageTags: []string{}, - UserData: UserData(mf.Annotations, mf.ID), - } - if fields.Has("MediaSources") { - item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)} - } - if fields.Has("SortName") { - item.SortName = sortName(mf.SortTitle, mf.OrderTitle, mf.Title) - } - // Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit). - // Participants holds the per-artist list; fall back to the flattened display fields when absent. - if artists := mf.Participants[model.RoleArtist]; len(artists) > 0 { - item.Artists = slice.Map(artists, func(p model.Participant) string { return p.Name }) - item.ArtistItems = slice.Map(artists, func(p model.Participant) NameGuidPair { - return NameGuidPair{Name: p.Name, Id: EncodeID(p.ID)} - }) - } else { - if mf.Artist != "" { - item.Artists = []string{mf.Artist} - } - if mf.ArtistID != "" { - item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} - } - } - if mf.AlbumArtistID != "" { - item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} - } - // dB to apply at the RG2 -18 LUFS reference, same convention real Jellyfin uses; no conversion. - item.NormalizationGain = mf.RGTrackGain - item.AlbumNormalizationGain = mf.RGAlbumGain - if mf.Year > 0 { - item.ProductionYear = new(mf.Year) - } - item.PremiereDate = premiereDate(mf.Date, mf.Year) - if mf.TrackNumber > 0 { - item.IndexNumber = new(mf.TrackNumber) - } - if mf.DiscNumber > 0 { - item.ParentIndexNumber = new(mf.DiscNumber) - } - if len(mf.Genres) > 0 { - for _, g := range mf.Genres { - item.Genres = append(item.Genres, g.Name) - item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) - } - } else if mf.Genre != "" { - item.Genres = []string{mf.Genre} - } - // Clients prefer ImageTags.Primary over AlbumId, so a track's own cover must win here. - if mf.ImageHash != "" && mf.ImageHash != mf.AlbumImage.ImageHash { - tag, blurs, ratio := primaryImage(mf.ItemImage, mf.ID, fields) - item.ImageTags = map[string]string{"Primary": tag} - item.ImageBlurHashes = blurs - item.PrimaryImageAspectRatio = ratio - } else if embeddedArtPending(mf) { - // Nothing enqueues media files: advertising the id is what makes a client ask, and that - // request is what extracts the embedded art and queues the track. - item.ImageTags = map[string]string{"Primary": mf.ID} - } else if mf.AlbumID != "" { - if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" { - item.AlbumPrimaryImageTag = tag - item.ImageBlurHashes = blurs - item.PrimaryImageAspectRatio = ratio - } - } - return item -} - -func embeddedArtPending(mf model.MediaFile) bool { - return mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt && - mf.ImageHash == "" && !mf.ItemImage.ImageAbsent -} - -// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would -// pin a stale cover forever. -func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) { - if img.ImageAbsent { - return "", nil, nil - } - tag = cmp.Or(img.ImageHash, fallback) - if img.BlurHash != "" { - blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}} - } - if fields.Has("PrimaryImageAspectRatio") { - ratio = img.AspectRatio() - } - return tag, blurs, ratio -} - -func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { - tag, blurs, ratio := primaryImage(al.ItemImage, al.ID, fields) - item := BaseItemDto{ - Name: al.Name, - Id: EncodeID(al.ID), - Type: "MusicAlbum", - IsFolder: true, - LocationType: "FileSystem", - ParentId: EncodeID(al.AlbumArtistID), - AlbumArtist: al.AlbumArtist, - Album: al.Name, - ChildCount: new(al.SongCount), - SongCount: new(al.SongCount), - RunTimeTicks: TicksFromSeconds(al.Duration), - DateCreated: JellyfinDate(&al.CreatedAt), - ImageBlurHashes: blurs, - PrimaryImageAspectRatio: ratio, - BackdropImageTags: []string{}, - UserData: UserData(al.Annotations, al.ID), - } - if tag != "" { - item.ImageTags = map[string]string{"Primary": tag} - } - item.Artists = []string{} - if al.AlbumArtist != "" { - item.Artists = append(item.Artists, al.AlbumArtist) - } - if al.AlbumArtistID != "" { - item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}} - item.ArtistItems = item.AlbumArtists - } - if al.MaxYear > 0 { - item.ProductionYear = new(al.MaxYear) - } - item.PremiereDate = premiereDate(al.Date, al.MaxYear) - if len(al.Genres) > 0 { - for _, g := range al.Genres { - item.Genres = append(item.Genres, g.Name) - item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) - } - } - // Jellyfin leaves Studios empty for music; we expose record labels here to match our /Studios - // list and StudioIds= filter, so a client can display and click through to filter by label. - if fields.Has("Studios") { - for _, label := range al.Tags.Values(model.TagRecordLabel) { - id := EncodeID(model.NewTag(model.TagRecordLabel, label).ID) - item.Studios = append(item.Studios, NameGuidPair{Name: label, Id: id}) - } - } - // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same - // convention as tracks; clients read it off the album item as NormalizationGain. - item.NormalizationGain = al.RGAlbumGain - if fields.Has("SortName") { - item.SortName = sortName(al.SortAlbumName, al.OrderAlbumName, al.Name) - } - return item -} - -func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { - tag, blurs, ratio := primaryImage(ar.ItemImage, ar.ID, fields) - item := BaseItemDto{ - Name: ar.Name, - Id: EncodeID(ar.ID), - Type: "MusicArtist", - IsFolder: true, - AlbumCount: new(ar.AlbumCount), - SongCount: new(ar.SongCount), - DateCreated: JellyfinDate(ar.CreatedAt), - ImageBlurHashes: blurs, - PrimaryImageAspectRatio: ratio, - BackdropImageTags: []string{}, - UserData: UserData(ar.Annotations, ar.ID), - } - if tag != "" { - item.ImageTags = map[string]string{"Primary": tag} - } - if fields.Has("SortName") { - item.SortName = sortName(ar.SortArtistName, ar.OrderArtistName, ar.Name) - } - return item -} - -// LibraryToBaseItem maps a library to the CollectionFolder item clients browse as a top-level node. -// Manet keeps no library, and so syncs nothing, unless it carries the fields Jellyfin sends here. -func LibraryToBaseItem(lib model.Library) BaseItemDto { - id := EncodeLibraryID(lib.ID) - return BaseItemDto{ - Id: id, - Name: lib.Name, - SortName: lib.Name, - Type: "CollectionFolder", - CollectionType: "music", - IsFolder: true, - Path: lib.Path, - LocationType: "FileSystem", - DateCreated: JellyfinDate(&lib.CreatedAt), - ChildCount: new(lib.TotalAlbums), - UserData: &UserItemDataDto{Key: id, ItemId: id}, - BackdropImageTags: []string{}, - } -} - -func GenreToBaseItem(g model.Genre) BaseItemDto { - return BaseItemDto{ - Name: g.Name, - Id: EncodeID(g.ID), - Type: "MusicGenre", - IsFolder: true, - BackdropImageTags: []string{}, - } -} - -func StudioToBaseItem(t model.Tag) BaseItemDto { - return BaseItemDto{ - Name: t.TagValue, - Id: EncodeID(t.ID), - Type: "Studio", - BackdropImageTags: []string{}, - } -} - -// PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. -func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto { - tag, blurs, ratio := primaryImage(p.ItemImage, p.ID, fields) - item := BaseItemDto{ - Name: p.Name, - Id: EncodeID(p.ID), - Type: "Playlist", - // Synthetic path: Jellify only surfaces playlists whose Path contains "data" (real Jellyfin - // stores them under its data folder), so without this its Playlists tab hides them all. - Path: "/data/playlists/" + p.ID, - IsFolder: true, - MediaType: "Audio", - ChildCount: new(p.SongCount), - RunTimeTicks: TicksFromSeconds(p.Duration), - DateCreated: JellyfinDate(&p.CreatedAt), - ImageBlurHashes: blurs, - PrimaryImageAspectRatio: ratio, - BackdropImageTags: []string{}, - UserData: UserData(p.Annotations, p.ID), - } - if tag != "" { - item.ImageTags = map[string]string{"Primary": tag} - } - // Playlists have no sort tag; the repository orders them by name. - if fields.Has("SortName") { - item.SortName = p.Name - } - return item -} - -// LyricDtoFromLyrics maps one lyric track to Jellyfin's LyricDto. Clients infer synced-vs-plain -// from per-line Start presence, so synced drops start-less lines and unsynced never emits Start. -func LyricDtoFromLyrics(mf model.MediaFile, lyrics model.Lyrics) LyricDto { - d := LyricDto{ - Metadata: LyricMetadata{ - Artist: cmp.Or(lyrics.DisplayArtist, mf.Artist), - Album: mf.Album, - Title: cmp.Or(lyrics.DisplayTitle, mf.Title), - Length: TicksFromSeconds(mf.Duration), - IsSynced: lyrics.Synced, - }, - Lyrics: make([]LyricLine, 0, len(lyrics.Line)), - } - if lyrics.Offset != nil { - offset := TicksFromMillis(*lyrics.Offset) - d.Metadata.Offset = &offset - } - for _, line := range lyrics.Line { - out := LyricLine{Text: line.Value} - if lyrics.Synced { - if line.Start == nil { - continue - } - start := TicksFromMillis(*line.Start) - out.Start = &start - for _, cue := range line.Cue { - if cue.Start == nil { - continue - } - c := LyricLineCue{ - Position: cue.ByteStart, - EndPosition: cue.ByteEnd, - Start: TicksFromMillis(*cue.Start), - } - if cue.End != nil { - end := TicksFromMillis(*cue.End) - c.End = &end - } - out.Cues = append(out.Cues, c) - } - } - d.Lyrics = append(d.Lyrics, out) - } - return d -} - -// NewSessionInfo's Id is stable per client install, as Jellyfin reuses a device's session across logins. -func NewSessionInfo(u *model.User, client, deviceID, deviceName, version, serverID string) *SessionInfo { - now := time.Now() - return &SessionInfo{ - Id: EncodeID(id.NewHash(client, deviceID)), - UserId: EncodeID(u.ID), - UserName: u.UserName, - Client: client, - DeviceId: deviceID, - DeviceName: deviceName, - ApplicationVersion: version, - ServerId: serverID, - LastActivityDate: JellyfinDate(&now), - IsActive: true, - PlayableMediaTypes: []string{"Audio"}, - SupportedCommands: []string{}, - AdditionalUsers: []any{}, - NowPlayingQueue: []any{}, - PlayState: PlayerStateInfo{RepeatMode: "RepeatNone", PlaybackOrder: "Default"}, - } -} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go deleted file mode 100644 index 259904673..000000000 --- a/server/jellyfin/dto/mappers_test.go +++ /dev/null @@ -1,768 +0,0 @@ -package dto - -import ( - "encoding/json" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("mappers", func() { - It("maps a song to an Audio BaseItemDto", func() { - mf := model.MediaFile{ - ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), - Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, - Year: 1999, Duration: 60, Size: 2_500_000, - Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}, - PlayCount: 2, - Starred: true, - } - item := SongToBaseItem(mf, nil) - Expect(item.Type).To(Equal("Audio")) - Expect(item.MediaType).To(Equal("Audio")) - Expect(item.IsFolder).To(BeFalse()) - Expect(item.LocationType).To(Equal("FileSystem")) - Expect(item.Id).To(Equal(EncodeID(testID("song-1")))) - Expect(item.AlbumId).To(Equal(EncodeID(testID("alb-1")))) - Expect(item.ParentId).To(Equal(EncodeID(testID("alb-1")))) - Expect(item.RunTimeTicks).To(Equal(int64(600_000_000))) - Expect(*item.IndexNumber).To(Equal(3)) - Expect(item.UserData.IsFavorite).To(BeTrue()) - Expect(item.UserData.PlayCount).To(Equal(2)) - Expect(item.UserData.Played).To(BeTrue()) - Expect(item.UserData.Key).To(Equal(EncodeID(testID("song-1")))) - Expect(item.UserData.ItemId).To(Equal(EncodeID(testID("song-1")))) - Expect(item.AlbumPrimaryImageTag).To(Equal(testID("alb-1"))) - Expect(item.ImageBlurHashes).To(BeNil()) - Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) - Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}})) - }) - - Describe("Fields gating (matches real Jellyfin)", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, - SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`} - - It("omits MediaSources when Fields does not ask for them", func() { - item := SongToBaseItem(mf, nil) - Expect(item.MediaSources).To(BeNil()) - }) - - It("includes MediaSources only when Fields=MediaSources", func() { - item := SongToBaseItem(mf, ParseFields("ChildCount,MediaSources,SortName")) - Expect(item.MediaSources).To(HaveLen(1)) - Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000))) - }) - - // SortName must match the server sort order — see the sortName helper. - Describe("SortName", func() { - song := model.MediaFile{ID: testID("s1"), Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"} - ar := model.Artist{ID: testID("art-1"), Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"} - al := model.Album{ID: testID("alb-1"), Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"} - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("is omitted unless Fields=SortName", func() { - Expect(SongToBaseItem(song, nil).SortName).To(BeEmpty()) - Expect(ArtistToBaseItem(ar, nil).SortName).To(BeEmpty()) - Expect(AlbumToBaseItem(al, nil).SortName).To(BeEmpty()) - }) - - It("uses the order names by default, ignoring sort tags", func() { - Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("song")) - Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("b-52's")) - Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("wall")) - }) - - Context("with PreferSortTags", func() { - BeforeEach(func() { - conf.Server.PreferSortTags = true - }) - - It("prefers the sort tags", func() { - Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("Song, The")) - Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("B-52's, The")) - Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("Wall, The")) - }) - - It("falls back to the order name when there is no sort tag", func() { - Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "The X", OrderArtistName: "x"}, - ParseFields("SortName")).SortName).To(Equal("x")) - }) - }) - - It("falls back to the display name when order name and sort tag are empty", func() { - Expect(SongToBaseItem(model.MediaFile{ID: testID("s"), Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T")) - Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N")) - Expect(AlbumToBaseItem(model.Album{ID: testID("al"), Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A")) - }) - }) - - It("sets HasLyrics from the media file's lyrics", func() { - Expect(*SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) - Expect(*SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) - // "[]" is the no-lyrics sentinel, not a truthy value. - Expect(*SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) - }) - }) - - It("omits ImageBlurHashes when a song has no album", func() { - mf := model.MediaFile{ID: testID("song-noalbum"), Title: "Song", Duration: 60} - item := SongToBaseItem(mf, nil) - Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("sets DateCreated from the media file's CreatedAt", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} - Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00.0000000Z")) - }) - - It("omits DateCreated when CreatedAt is the zero time", func() { - Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).DateCreated).To(BeEmpty()) - }) - - It("sets ArtistItems and AlbumArtists (encoded ids) from the track and album artist", func() { - mf := model.MediaFile{ - ID: testID("s1"), Title: "Song", - Artist: "The Band", ArtistID: testID("ar-1"), - AlbumArtist: "Various", AlbumArtistID: testID("ar-2"), - } - item := SongToBaseItem(mf, nil) - Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID(testID("ar-1"))}})) - Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID(testID("ar-2"))}})) - }) - - It("omits ArtistItems when the track has no artist id", func() { - Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) - }) - - It("omits Artists when the track has no artist name or participants", func() { - Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).Artists).To(BeNil()) - }) - - It("splits Artists and ArtistItems per track artist from Participants", func() { - mf := model.MediaFile{ - ID: testID("s1"), Title: "Oooh", - Artist: "De La Soul feat. Redman", ArtistID: testID("ar-delasoul"), - AlbumArtist: "De La Soul", AlbumArtistID: testID("ar-delasoul"), - } - mf.Participants = model.Participants{ - model.RoleArtist: model.ParticipantList{ - {Artist: model.Artist{ID: testID("ar-delasoul"), Name: "De La Soul"}}, - {Artist: model.Artist{ID: testID("ar-redman"), Name: "Redman"}}, - }, - } - item := SongToBaseItem(mf, nil) - Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"})) - Expect(item.ArtistItems).To(Equal([]NameGuidPair{ - {Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}, - {Name: "Redman", Id: EncodeID(testID("ar-redman"))}, - })) - // AlbumArtists stays single, matching real Jellyfin. - Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}})) - }) - - It("serializes normalization gains with Jellyfin's exact key casing", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", - RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)} - b, err := json.Marshal(SongToBaseItem(mf, nil)) - Expect(err).ToNot(HaveOccurred()) - Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-3.5`)) - Expect(string(b)).To(ContainSubstring(`"AlbumNormalizationGain":-4.25`)) - }) - - It("omits normalization gains when the file has no ReplayGain tags", func() { - b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil)) - Expect(err).ToNot(HaveOccurred()) - // Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain). - Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) - }) - - It("builds a MediaSourceInfo from a media file", func() { - mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} - src := MediaSourceFromMediaFile(mf) - Expect(src.Id).To(Equal(EncodeID(testID("s1")))) - Expect(src.Size).To(Equal(int64(5242880))) - Expect(src.Container).To(Equal("mp3")) - Expect(src.Bitrate).To(Equal(320_000)) - Expect(src.RunTimeTicks).To(Equal(int64(1_000_000_000))) - Expect(src.Protocol).To(Equal("Http")) - Expect(src.SupportsDirectPlay).To(BeTrue()) - }) - - It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() { - mf := model.MediaFile{ - ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100, - Channels: 2, SampleRate: 44100, Codec: "mp3", - } - src := MediaSourceFromMediaFile(mf) - Expect(src.MediaStreams).To(HaveLen(1)) - stream := src.MediaStreams[0] - Expect(stream.Type).To(Equal("Audio")) - Expect(stream.Channels).To(Equal(2)) - Expect(stream.SampleRate).To(Equal(44100)) - Expect(stream.BitRate).To(Equal(320_000)) - Expect(stream.Codec).To(Equal("mp3")) - Expect(stream.ChannelLayout).To(Equal("stereo")) - }) - - It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() { - mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} - src := MediaSourceFromMediaFile(mf) - b, err := json.Marshal(src) - Expect(err).ToNot(HaveOccurred()) - j := string(b) - Expect(j).To(ContainSubstring(`"SupportsProbing":true`)) - Expect(j).To(ContainSubstring(`"IsInfiniteStream":false`)) - Expect(j).To(ContainSubstring(`"RequiresOpening":false`)) - Expect(j).To(ContainSubstring(`"MediaAttachments":[]`)) - Expect(j).To(ContainSubstring(`"Formats":[]`)) - }) - - It("serializes MediaStream's required non-nullable bools, never omitted", func() { - stream := MediaStream{Type: "Audio", Index: 0} - b, err := json.Marshal(stream) - Expect(err).ToNot(HaveOccurred()) - j := string(b) - Expect(j).To(ContainSubstring(`"Type":"Audio"`)) - Expect(j).To(ContainSubstring(`"IsDefault":false`)) - Expect(j).To(ContainSubstring(`"IsInterlaced":false`)) - Expect(j).To(ContainSubstring(`"IsForced":false`)) - Expect(j).To(ContainSubstring(`"IsExternal":false`)) - Expect(j).To(ContainSubstring(`"IsTextSubtitleStream":false`)) - Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`)) - }) - - Describe("Lyric media stream advertising", func() { - It("adds a Lyric media stream when the file has embedded lyrics", func() { - mf := model.MediaFile{ID: testID("s1"), Lyrics: `[{"line":[{"value":"la"}]}]`} - src := MediaSourceFromMediaFile(mf) - Expect(src.MediaStreams).To(HaveLen(2)) - Expect(src.MediaStreams[0].Type).To(Equal("Audio")) - Expect(src.MediaStreams[1].Type).To(Equal("Lyric")) - Expect(src.MediaStreams[1].Index).To(Equal(1)) - Expect(src.MediaStreams[1].IsExternal).To(BeTrue()) - }) - - It("emits only the Audio stream without lyrics", func() { - src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1")}) - Expect(src.MediaStreams).To(HaveLen(1)) - Expect(src.MediaStreams[0].Type).To(Equal("Audio")) - }) - - It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() { - src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1"), Lyrics: "[]"}) - Expect(src.MediaStreams).To(HaveLen(1)) - }) - }) - - It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() { - mf := model.MediaFile{ - ID: testID("song-2"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), - Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0, - Duration: 60, - } - item := SongToBaseItem(mf, nil) - Expect(item.IndexNumber).To(BeNil()) - Expect(item.ParentIndexNumber).To(BeNil()) - }) - - It("maps PlayDate to UserData.LastPlayedDate", func() { - playDate := time.Date(2023, 5, 17, 12, 30, 0, 0, time.UTC) - mf := model.MediaFile{ - ID: testID("song-3"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), - Artist: "Art", AlbumArtist: "AA", Duration: 60, - } - mf.PlayDate = &playDate - item := SongToBaseItem(mf, nil) - Expect(item.UserData.LastPlayedDate).NotTo(BeNil()) - Expect(*item.UserData.LastPlayedDate).To(Equal("2023-05-17T12:30:00.0000000Z")) - }) - - It("maps an album to a MusicAlbum folder item", func() { - al := model.Album{ID: testID("alb-1"), Name: "Alb", AlbumArtist: "AA", AlbumArtistID: testID("art-1"), MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}} - item := AlbumToBaseItem(al, nil) - Expect(item.Type).To(Equal("MusicAlbum")) - Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID(testID("alb-1")))) - Expect(item.ParentId).To(Equal(EncodeID(testID("art-1")))) - Expect(item.AlbumArtists).To(HaveLen(1)) - Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID(testID("art-1")))) - Expect(item.ArtistItems).To(Equal(item.AlbumArtists)) - Expect(*item.ProductionYear).To(Equal(1999)) - Expect(*item.ChildCount).To(Equal(10)) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-1"))) - Expect(item.ImageBlurHashes).To(BeNil()) - // Jellyfin sends both on every album; Manet stops its sync on an album without them. - Expect(item.Artists).To(Equal([]string{"AA"})) - Expect(item.LocationType).To(Equal("FileSystem")) - Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) - Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}})) - }) - - It("populates album Studios from record-label tags only when Fields=Studios", func() { - al := model.Album{ID: testID("alb-2"), Name: "Alb2"} - al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}} - - Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty()) - - item := AlbumToBaseItem(al, ParseFields("Studios")) - Expect(item.Studios).To(Equal([]NameGuidPair{ - {Name: "Columbia", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Columbia").ID)}, - {Name: "Legacy", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Legacy").ID)}, - })) - }) - - It("sets NormalizationGain on the album from its ReplayGain", func() { - al := model.Album{ID: testID("al1"), Name: "Album", RGAlbumGain: new(-6.0)} - b, err := json.Marshal(AlbumToBaseItem(al, nil)) - Expect(err).ToNot(HaveOccurred()) - Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) - // Real Jellyfin never sets AlbumNormalizationGain on an album item. - Expect(string(b)).ToNot(ContainSubstring("AlbumNormalizationGain")) - }) - - It("omits NormalizationGain when the album has no ReplayGain", func() { - b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: testID("al1"), Name: "Album"}, nil)) - Expect(err).ToNot(HaveOccurred()) - Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) - }) - - Describe("PrimaryImageAspectRatio", func() { - nonSquare := func() model.Album { - al := model.Album{ID: testID("al1"), Name: "Album"} - al.ImageHash, al.ImageWidth, al.ImageHeight = "abc", 1200, 800 - return al - } - - It("is omitted unless the request asks for it", func() { - Expect(AlbumToBaseItem(nonSquare(), nil).PrimaryImageAspectRatio).To(BeNil()) - b, err := json.Marshal(AlbumToBaseItem(nonSquare(), nil)) - Expect(err).ToNot(HaveOccurred()) - Expect(string(b)).ToNot(ContainSubstring("PrimaryImageAspectRatio")) - }) - - It("carries the real ratio when asked", func() { - item := AlbumToBaseItem(nonSquare(), ParseFields("PrimaryImageAspectRatio")) - Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001)) - }) - - It("is omitted when the dimensions are unknown, rather than guessing square", func() { - al := model.Album{ID: testID("al1"), Name: "Album"} - al.ImageHash = "abc" - item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) - Expect(item.PrimaryImageAspectRatio).To(BeNil()) - }) - - It("is omitted when the item has no image at all", func() { - al := model.Album{ID: testID("al1"), Name: "Album"} - al.ImageAbsent = true - al.ImageWidth, al.ImageHeight = 1200, 800 - item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) - Expect(item.PrimaryImageAspectRatio).To(BeNil()) - }) - - It("carries the ratio for an artist", func() { - ar := model.Artist{ID: testID("ar1"), Name: "Artist"} - ar.ImageHash, ar.ImageWidth, ar.ImageHeight = "abc", 1000, 500 - Expect(*ArtistToBaseItem(ar, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). - To(BeNumerically("~", 2.0, 0.0001)) - }) - - It("carries the ratio for a playlist", func() { - pl := model.Playlist{ID: testID("pl1"), Name: "Playlist"} - pl.ImageHash, pl.ImageWidth, pl.ImageHeight = "abc", 400, 800 - Expect(*PlaylistToBaseItem(pl, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). - To(BeNumerically("~", 0.5, 0.0001)) - }) - - It("carries the ratio for a song with its own art", func() { - mf := model.MediaFile{ID: testID("mf1"), Title: "Song"} - mf.ImageHash, mf.ImageWidth, mf.ImageHeight = "abc", 300, 600 - Expect(*SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). - To(BeNumerically("~", 0.5, 0.0001)) - }) - - // A track without its own art shows the album's, so the ratio has to describe that image. - It("uses the album's dimensions for a track falling back to album art", func() { - mf := model.MediaFile{ID: testID("mf1"), Title: "Song", AlbumID: testID("al1")} - mf.AlbumImage.ImageHash, mf.AlbumImage.ImageWidth, mf.AlbumImage.ImageHeight = "abc", 1200, 800 - item := SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")) - Expect(item.AlbumPrimaryImageTag).To(Equal("abc")) - Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001)) - }) - }) - - It("maps an artist to a MusicArtist folder item", func() { - ar := model.Artist{ID: testID("art-1"), Name: "AA", AlbumCount: 2, SongCount: 20} - item := ArtistToBaseItem(ar, nil) - Expect(item.Type).To(Equal("MusicArtist")) - Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID(testID("art-1")))) - Expect(*item.AlbumCount).To(Equal(2)) - }) - - It("maps a genre to a MusicGenre folder item", func() { - g := model.Genre{ID: testID("genre-1"), Name: "Rock"} - item := GenreToBaseItem(g) - Expect(item.Type).To(Equal("MusicGenre")) - Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID(testID("genre-1")))) - Expect(item.Name).To(Equal("Rock")) - }) - - It("maps a tag to a Studio BaseItemDto", func() { - item := StudioToBaseItem(model.Tag{ID: testID("t1"), TagValue: "Blue Note"}) - Expect(item.Type).To(Equal("Studio")) - Expect(item.Name).To(Equal("Blue Note")) - Expect(item.Id).To(Equal(EncodeID(testID("t1")))) - }) - - Describe("premiereDate", func() { - // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. - It("serializes a full date", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02-01", Year: 2007} - item := SongToBaseItem(mf, nil) - Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z")) - }) - - It("pads a year-only date so clients can parse it", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007", Year: 2007} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00.0000000Z")) - }) - - It("pads a year-month date", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02"} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z")) - }) - - It("falls back to the year when no date tag exists", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Year: 1999} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00.0000000Z")) - }) - - It("is omitted when the track has no date at all", func() { - Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).PremiereDate).To(BeNil()) - }) - - It("is set on albums from their date, falling back to MaxYear", func() { - Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00.0000000Z")) - Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00.0000000Z")) - Expect(AlbumToBaseItem(model.Album{ID: testID("a3")}, nil).PremiereDate).To(BeNil()) - }) - }) - - It("maps a playlist to a Playlist BaseItemDto", func() { - p := model.Playlist{ - ID: testID("pl-1"), Name: "Chill", SongCount: 7, Duration: 120, - Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2}, - } - item := PlaylistToBaseItem(p, nil) - Expect(item.Type).To(Equal("Playlist")) - Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID(testID("pl-1")))) - Expect(item.Name).To(Equal("Chill")) - Expect(item.MediaType).To(Equal("Audio")) - Expect(*item.ChildCount).To(Equal(7)) - Expect(item.RunTimeTicks).To(Equal(int64(1_200_000_000))) - Expect(item.UserData.IsFavorite).To(BeTrue()) - Expect(item.UserData.PlayCount).To(Equal(2)) - Expect(*item.UserData.Rating).To(Equal(8.0)) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("pl-1"))) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("emits DateCreated and SortName on playlists, like albums and artists", func() { - p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)} - Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z")) - Expect(PlaylistToBaseItem(p, nil).SortName).To(BeEmpty()) - Expect(PlaylistToBaseItem(p, ParseFields("SortName")).SortName).To(Equal("Chill")) - }) - - It("changes the playlist image tag when the cover content changes", func() { - p := model.Playlist{ID: testID("pl-1"), Name: "Chill"} - p.ImageHash = "1111111111111111" - before := PlaylistToBaseItem(p, nil) - p.ImageHash = "2222222222222222" - after := PlaylistToBaseItem(p, nil) - - Expect(before.ImageTags["Primary"]).To(Equal("1111111111111111")) - Expect(after.ImageTags["Primary"]).To(Equal("2222222222222222")) - }) - - It("keeps the playlist image tag stable across a metadata-only edit", func() { - p := model.Playlist{ID: testID("pl-1"), UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} - p.ImageHash = "1111111111111111" - before := PlaylistToBaseItem(p, nil) - p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) - after := PlaylistToBaseItem(p, nil) - - Expect(after.ImageTags["Primary"]).To(Equal(before.ImageTags["Primary"])) - }) - - // Nothing enqueues media files, so a track's own art only resolves when a client requests it; - // advertising just the album image would leave that cover unreachable. - Describe("unresolved embedded art", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableMediaFileCoverArt = true - }) - - It("advertises the track id so the client triggers the read-through", func() { - mf := model.MediaFile{ID: testID("mf-1"), AlbumID: testID("alb-1"), HasCoverArt: true} - mf.AlbumImage.ImageHash = "0123456789abcdef" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("mf-1"))) - Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send") - Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) - }) - - It("falls back to the album when the track has no art of its own", func() { - mf := model.MediaFile{ID: testID("mf-2"), AlbumID: testID("alb-1"), HasCoverArt: false} - mf.AlbumImage.ImageHash = "0123456789abcdef" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(BeEmpty()) - Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - }) - - It("falls back to the album once the track's art is known absent", func() { - mf := model.MediaFile{ID: testID("mf-3"), AlbumID: testID("alb-1"), HasCoverArt: true} - mf.ItemImage.ImageAbsent = true - mf.AlbumImage.ImageHash = "0123456789abcdef" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(BeEmpty()) - Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - }) - - It("falls back to the album when per-track art is disabled", func() { - conf.Server.EnableMediaFileCoverArt = false - mf := model.MediaFile{ID: testID("mf-4"), AlbumID: testID("alb-1"), HasCoverArt: true} - mf.AlbumImage.ImageHash = "0123456789abcdef" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(BeEmpty()) - Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - }) - }) - - Describe("primary image tags", func() { - It("uses the content hash as the tag and emits the real blurhash", func() { - al := model.Album{ID: testID("alb-1"), Name: "Album"} - al.ImageHash = "0123456789abcdef" - al.BlurHash = "LEHV6nWB2yk8" - - item := AlbumToBaseItem(al, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) - }) - - It("omits the blurhash entirely when none was computed", func() { - al := model.Album{ID: testID("alb-2"), Name: "Album"} - al.ImageHash = "0123456789abcdef" - - item := AlbumToBaseItem(al, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) - Expect(item.ImageBlurHashes).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp") - }) - - It("omits tags for known-absent artwork", func() { - al := model.Album{ID: testID("alb-3"), Name: "Album"} - al.ImageAbsent = true - - item := AlbumToBaseItem(al, nil) - Expect(item.ImageTags).To(BeEmpty()) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("falls back to the entity id while artwork is still unresolved", func() { - item := AlbumToBaseItem(model.Album{ID: testID("alb-4"), Name: "Album"}, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-4"))) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("versions an artist's tag by content hash", func() { - ar := model.Artist{ID: testID("art-1"), Name: "Artist"} - ar.ImageHash = "fedcba9876543210" - ar.BlurHash = "L6PZfSi_.AyE" - - item := ArtistToBaseItem(ar, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "fedcba9876543210")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("fedcba9876543210", "L6PZfSi_.AyE")) - }) - }) - - Describe("song and playlist image tags", func() { - It("versions a song's album tag by the album's content hash", func() { - mf := model.MediaFile{ID: testID("song-1"), Title: "Song", AlbumID: testID("alb-1")} - mf.AlbumImage.ImageHash = "0123456789abcdef" - mf.AlbumImage.BlurHash = "LEHV6nWB2yk8" - - item := SongToBaseItem(mf, nil) - Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) - }) - - It("never synthesizes a song blurhash when the album has none", func() { - mf := model.MediaFile{ID: testID("song-2"), Title: "Song", AlbumID: testID("alb-2")} - mf.AlbumImage.ImageHash = "0123456789abcdef" - - item := SongToBaseItem(mf, nil) - Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("omits a song's album tag when the album art is known absent", func() { - mf := model.MediaFile{ID: testID("song-3"), Title: "Song", AlbumID: testID("alb-3")} - mf.AlbumImage.ImageAbsent = true - - item := SongToBaseItem(mf, nil) - Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("versions a playlist tag by content hash instead of UpdatedAt", func() { - pl := model.Playlist{ID: testID("pl-1"), Name: "Playlist"} - pl.ImageHash = "abcdef0123456789" - - item := PlaylistToBaseItem(pl, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "abcdef0123456789")) - }) - }) - - Describe("per-song artwork", func() { - It("emits the track's own Primary tag when it has distinct art", func() { - mf := model.MediaFile{ID: testID("song-own"), Title: "Song", AlbumID: testID("alb-1")} - mf.ImageHash = "aaaaaaaaaaaaaaaa" - mf.BlurHash = "LTRACKblur" - mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" - mf.AlbumImage.BlurHash = "LALBUMblur" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "aaaaaaaaaaaaaaaa")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1), - "exactly one Primary entry: Go sorts map keys, so a second entry could pair the wrong blurhash with imageId") - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("aaaaaaaaaaaaaaaa", "LTRACKblur")) - }) - - It("falls back to the album tag when the track has no distinct art", func() { - mf := model.MediaFile{ID: testID("song-inherit"), Title: "Song", AlbumID: testID("alb-1")} - mf.ImageHash = "bbbbbbbbbbbbbbbb" - mf.BlurHash = "LALBUMblur" - mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" - mf.AlbumImage.BlurHash = "LALBUMblur" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(BeEmpty(), "an inherited cover is the album's image, not the track's") - Expect(item.AlbumPrimaryImageTag).To(Equal("bbbbbbbbbbbbbbbb")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1)) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("bbbbbbbbbbbbbbbb", "LALBUMblur")) - }) - - It("omits the track tag when its own art is known absent", func() { - mf := model.MediaFile{ID: testID("song-absent"), Title: "Song", AlbumID: testID("alb-1")} - mf.ImageAbsent = true - mf.AlbumImage.ImageAbsent = true - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(BeEmpty()) - Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - }) -}) - -var _ = Describe("LyricDtoFromLyrics", func() { - ms := func(v int64) *int64 { return &v } - - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "Artist", Album: "Album", Duration: 100} - - It("maps synced lyrics with tick conversion", func() { - l := model.Lyrics{ - DisplayArtist: "Display Artist", - DisplayTitle: "Display Title", - Synced: true, - Offset: ms(-150), - Line: []model.Line{ - {Start: ms(1000), Value: "line one"}, - {Start: ms(2500), Value: "line two"}, - }, - } - d := LyricDtoFromLyrics(mf, l) - Expect(d.Metadata.Artist).To(Equal("Display Artist")) - Expect(d.Metadata.Title).To(Equal("Display Title")) - Expect(d.Metadata.Album).To(Equal("Album")) - Expect(d.Metadata.IsSynced).To(BeTrue()) - Expect(*d.Metadata.Offset).To(Equal(int64(-1_500_000))) - Expect(d.Metadata.Length).To(Equal(TicksFromSeconds(100))) - Expect(d.Lyrics).To(HaveLen(2)) - Expect(d.Lyrics[0].Text).To(Equal("line one")) - Expect(*d.Lyrics[0].Start).To(Equal(int64(10_000_000))) - Expect(*d.Lyrics[1].Start).To(Equal(int64(25_000_000))) - }) - - It("falls back to the media file's artist and title", func() { - d := LyricDtoFromLyrics(mf, model.Lyrics{Line: []model.Line{{Value: "x"}}}) - Expect(d.Metadata.Artist).To(Equal("Artist")) - Expect(d.Metadata.Title).To(Equal("Song")) - }) - - It("drops start-less lines from synced lyrics", func() { - l := model.Lyrics{Synced: true, Line: []model.Line{ - {Start: ms(0), Value: "kept"}, - {Value: "dropped"}, - }} - d := LyricDtoFromLyrics(mf, l) - Expect(d.Lyrics).To(HaveLen(1)) - Expect(d.Lyrics[0].Text).To(Equal("kept")) - }) - - It("emits no Start on unsynced lyrics even when lines have one", func() { - l := model.Lyrics{Synced: false, Line: []model.Line{{Start: ms(1000), Value: "plain"}}} - d := LyricDtoFromLyrics(mf, l) - Expect(d.Lyrics).To(HaveLen(1)) - Expect(d.Lyrics[0].Start).To(BeNil()) - Expect(d.Metadata.IsSynced).To(BeFalse()) - }) - - It("maps word cues", func() { - end := int64(1500) - l := model.Lyrics{Synced: true, Line: []model.Line{{ - Start: ms(1000), - Value: "word cue", - Cue: []model.Cue{{Start: ms(1000), End: &end, Value: "word", ByteStart: 0, ByteEnd: 4}}, - }}} - d := LyricDtoFromLyrics(mf, l) - Expect(d.Lyrics[0].Cues).To(HaveLen(1)) - c := d.Lyrics[0].Cues[0] - Expect(c.Position).To(Equal(0)) - Expect(c.EndPosition).To(Equal(4)) - Expect(c.Start).To(Equal(int64(10_000_000))) - Expect(*c.End).To(Equal(int64(15_000_000))) - }) - - It("skips a start-less cue while keeping its sibling", func() { - l := model.Lyrics{Synced: true, Line: []model.Line{{ - Start: ms(1000), - Value: "word cue", - Cue: []model.Cue{ - {Start: nil, Value: "dropped", ByteStart: 0, ByteEnd: 7}, - {Start: ms(1000), Value: "kept", ByteStart: 8, ByteEnd: 12}, - }, - }}} - d := LyricDtoFromLyrics(mf, l) - Expect(d.Lyrics[0].Cues).To(HaveLen(1)) - c := d.Lyrics[0].Cues[0] - Expect(c.Position).To(Equal(8)) - Expect(c.EndPosition).To(Equal(12)) - Expect(c.Start).To(Equal(int64(10_000_000))) - }) -}) diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go deleted file mode 100644 index 182fd5436..000000000 --- a/server/jellyfin/e2e/annotations_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Annotations", func() { - BeforeEach(func() { setupTestDB() }) - - itemUserData := func(id string) *dto.UserItemDataDto { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(id)), &item) - return item.UserData - } - - Describe("favorites", func() { - It("marks and unmarks an album as favorite", func() { - id := albumID("Abbey Road") - - var marked dto.UserItemDataDto - parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &marked) - Expect(marked.IsFavorite).To(BeTrue()) - Expect(itemUserData(id).IsFavorite).To(BeTrue()) - - var unmarked dto.UserItemDataDto - parseInto(del("/Users/admin-1/FavoriteItems/"+enc(id)), &unmarked) - Expect(unmarked.IsFavorite).To(BeFalse()) - Expect(itemUserData(id).IsFavorite).To(BeFalse()) - }) - - It("marks a song as favorite", func() { - id := songID("So What") - var data dto.UserItemDataDto - parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &data) - Expect(itemUserData(id).IsFavorite).To(BeTrue()) - }) - - It("marks and unmarks via the current SDK endpoint /UserFavoriteItems/{id} (Jellify)", func() { - id := songID("Come Together") - - var marked dto.UserItemDataDto - parseInto(post("/UserFavoriteItems/"+enc(id), ""), &marked) - Expect(marked.IsFavorite).To(BeTrue()) - Expect(itemUserData(id).IsFavorite).To(BeTrue()) - - var unmarked dto.UserItemDataDto - parseInto(del("/UserFavoriteItems/"+enc(id)), &unmarked) - Expect(unmarked.IsFavorite).To(BeFalse()) - Expect(itemUserData(id).IsFavorite).To(BeFalse()) - }) - - It("filters items to favorites only", func() { - post("/Users/admin-1/FavoriteItems/"+enc(albumID("Abbey Road")), "") - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Filters=IsFavorite")) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("Abbey Road")) - }) - - It("marks and lists a playlist as favorite", func() { - id := createPlaylist("Favorite Mix", nil) - Expect(post("/Users/admin-1/FavoriteItems/"+enc(id), "").Code).To(Equal(http.StatusOK)) - Expect(itemUserData(id).IsFavorite).To(BeTrue()) - - q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&Filters=IsFavorite")) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("Favorite Mix")) - }) - - It("filters to favorites via the isFavorite query param (Finamp's artist widget form)", func() { - // Finamp's "Favourite tracks" widget sends isFavorite=true as a query param (not - // Filters=IsFavorite), combined with ArtistIds. - post("/Users/admin-1/FavoriteItems/"+enc(songID("Help!")), "") - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("The Beatles")) + "&isFavorite=true")) - Expect(names(q.Items)).To(ConsistOf("Help!")) - }) - - It("returns 404 when favoriting an unknown item", func() { - Expect(post("/Users/admin-1/FavoriteItems/"+enc(testID("nope")), "").Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("GET /UserItems/{id}/UserData", func() { - It("returns per-item favorite/played state (Jellify's played/favourite indicators)", func() { - id := songID("So What") - post("/Users/admin-1/FavoriteItems/"+enc(id), "") - - var data dto.UserItemDataDto - parseInto(get("/UserItems/"+enc(id)+"/UserData?userId=admin-1"), &data) - Expect(data.IsFavorite).To(BeTrue()) - Expect(data.ItemId).To(Equal(enc(id))) - }) - - It("returns a valid (unfavorited) UserData for an item with no annotations", func() { - var data dto.UserItemDataDto - parseInto(get("/UserItems/"+enc(albumID("Kind of Blue"))+"/UserData"), &data) - Expect(data.IsFavorite).To(BeFalse()) - Expect(data.ItemId).To(Equal(enc(albumID("Kind of Blue")))) - }) - - It("returns 404 for an unknown item", func() { - Expect(get("/UserItems/" + enc(testID("nope")) + "/UserData").Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("ratings", func() { - It("sets and clears an album rating (Jellyfin 0-10 scale)", func() { - id := albumID("IV") - - var set dto.UserItemDataDto - parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=10", ""), &set) - Expect(set.Rating).ToNot(BeNil()) - Expect(*set.Rating).To(Equal(float64(10))) - Expect(*itemUserData(id).Rating).To(Equal(float64(10))) - - // Fresh struct: the DELETE response omits the (now-nil) Rating field, so reusing `set` - // would leave the stale value. - var cleared dto.UserItemDataDto - parseInto(del("/Users/admin-1/Items/"+enc(id)+"/Rating"), &cleared) - Expect(cleared.Rating).To(BeNil()) - Expect(itemUserData(id).Rating).To(BeNil()) - }) - - It("sets and reads a playlist rating", func() { - id := createPlaylist("Rated Mix", nil) - Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK)) - Expect(*itemUserData(id).Rating).To(Equal(float64(8))) - }) - - It("clamps an out-of-range rating to the valid domain", func() { - id := albumID("Help!") - var data dto.UserItemDataDto - parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data) - // 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out. - Expect(data.Rating).ToNot(BeNil()) - Expect(*data.Rating).To(Equal(float64(10))) - }) - }) -}) diff --git a/server/jellyfin/e2e/audiomuse_test.go b/server/jellyfin/e2e/audiomuse_test.go deleted file mode 100644 index 074c8e1a4..000000000 --- a/server/jellyfin/e2e/audiomuse_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package e2e - -import ( - "net/http" - "strings" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/sonic" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("AudioMuse endpoints", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("GET /AudioMuseAI/info", func() { - It("returns version and available endpoints", func() { - var body struct { - Version string `json:"Version"` - AvailableEndpoints []string `json:"AvailableEndpoints"` - } - parseInto(get("/AudioMuseAI/info"), &body) - Expect(body.Version).To(Equal(consts.Version)) - Expect(body.AvailableEndpoints).To(ConsistOf( - "GET /AudioMuseAI/find_path", - "GET /AudioMuseAI/health", - "GET /AudioMuseAI/similar_tracks", - )) - }) - - It("requires authentication", func() { - Expect(rawReq("GET", "/AudioMuseAI/info", "").Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET /AudioMuseAI/health", func() { - It("returns 200 with an empty body when a provider is loaded", func() { - w := get("/AudioMuseAI/health") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Len()).To(Equal(0)) - }) - - It("requires authentication", func() { - Expect(rawReq("GET", "/AudioMuseAI/health", "").Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET /AudioMuseAI/similar_tracks", func() { - It("maps provider results to seeded tracks, encoding item ids", func() { - sonicProviderFake.similar = []sonic.SimilarResult{ - {Song: songAgent("Something"), Similarity: 0.3}, - {Song: songAgent("So What"), Similarity: 0.5}, - } - var body []struct { - Author string `json:"author"` - Distance float64 `json:"distance"` - ItemID string `json:"item_id"` - Title string `json:"title"` - } - parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Come Together"))+"&n=10"), &body) - Expect(body).To(HaveLen(2)) - Expect([]string{body[0].Title, body[1].Title}).To(ConsistOf("Something", "So What")) - decoded, ok := dto.DecodeID(body[0].ItemID) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(songID(body[0].Title))) - }) - - It("collapses to one track per artist by default", func() { - sonicProviderFake.similar = []sonic.SimilarResult{ - {Song: songAgent("Something"), Similarity: 0.3}, - {Song: songAgent("Come Together"), Similarity: 0.5}, - } - var body []map[string]any - parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Help!"))), &body) - Expect(body).To(HaveLen(1)) // both similar tracks are by The Beatles - }) - - It("returns an empty array (not null) when there are no results", func() { - sonicProviderFake.similar = nil - w := get("/AudioMuseAI/similar_tracks?item_id=" + enc(songID("Come Together"))) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) - }) - - It("requires authentication", func() { - Expect(rawReq("GET", "/AudioMuseAI/similar_tracks?item_id=x", "").Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET /AudioMuseAI/find_path", func() { - It("returns 400 with the exact message when a required id is missing", func() { - w := get("/AudioMuseAI/find_path?start_song_id=" + enc(songID("Something"))) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) - }) - - It("returns the path and summed total_distance", func() { - sonicProviderFake.path = []sonic.SimilarResult{ - {Song: songAgent("Come Together"), Similarity: 1.5}, - {Song: songAgent("So What"), Similarity: 2.0}, - } - var body struct { - Path []struct { - ItemID string `json:"item_id"` - Title string `json:"title"` - } `json:"path"` - TotalDistance float64 `json:"total_distance"` - } - parseInto(get("/AudioMuseAI/find_path?start_song_id="+enc(songID("Something"))+"&end_song_id="+enc(songID("So What"))+"&max_steps=10"), &body) - Expect(body.Path).To(HaveLen(2)) - Expect(body.TotalDistance).To(Equal(3.5)) - }) - }) -}) diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go deleted file mode 100644 index 35daaf598..000000000 --- a/server/jellyfin/e2e/auth_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package e2e - -import ( - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Authentication", func() { - BeforeEach(func() { setupTestDB() }) - - authenticate := func(username, pw string) *httptest.ResponseRecorder { - body := `{"Username":"` + username + `","Pw":"` + pw + `"}` - return rawReq("POST", "/Users/AuthenticateByName", body) - } - - Describe("POST /Users/AuthenticateByName", func() { - It("authenticates a valid user and returns a usable token", func() { - w := authenticate("admin", "password") - var res dto.AuthenticationResult - parseInto(w, &res) - Expect(res.AccessToken).ToNot(BeEmpty()) - Expect(res.User).ToNot(BeNil()) - Expect(res.User.Name).To(Equal("admin")) - Expect(res.User.Id).To(Equal(enc(testID("admin-1")))) - Expect(res.User.Policy.IsAdministrator).To(BeTrue()) - Expect(res.ServerId).To(MatchRegexp("^[0-9a-f]{32}$")) - - // The returned token must actually authenticate a protected request. - r := httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Token", res.AccessToken) - pw := httptest.NewRecorder() - router.ServeHTTP(pw, r) - Expect(pw.Code).To(Equal(http.StatusOK)) - }) - - It("marks a non-admin user's policy as non-administrator", func() { - w := authenticate("regular", "password") - var res dto.AuthenticationResult - parseInto(w, &res) - Expect(res.User.Policy.IsAdministrator).To(BeFalse()) - }) - - It("rejects a wrong password", func() { - Expect(authenticate("admin", "wrong").Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects an empty password", func() { - Expect(authenticate("admin", "").Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects an unknown user", func() { - Expect(authenticate("nobody", "password").Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects a malformed body", func() { - Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest)) - }) - - It("mints a non-expiring token scoped to the Jellyfin audience", func() { - w := authenticate("admin", "password") - var res dto.AuthenticationResult - parseInto(w, &res) - - claims, err := auth.Validate(res.AccessToken) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.ExpiresAt.IsZero()).To(BeTrue()) - Expect(claims.Audience).To(Equal([]string{"jellyfin"})) - Expect(claims.Subject).To(Equal("admin")) - }) - - It("revokes an already-issued token when the user's epoch is bumped", func() { - w := authenticate("admin", "password") - var res dto.AuthenticationResult - parseInto(w, &res) - - r := httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Token", res.AccessToken) - pw := httptest.NewRecorder() - router.ServeHTTP(pw, r) - Expect(pw.Code).To(Equal(http.StatusOK)) - - // A real password change through the repository, which is what revokes in production. - admin, err := ds.User().Get(ctx, testID("admin-1")) - Expect(err).ToNot(HaveOccurred()) - admin.NewPassword = "rotated" - Expect(ds.User().Put(ctx, admin)).To(Succeed()) - - r = httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Token", res.AccessToken) - pw = httptest.NewRecorder() - router.ServeHTTP(pw, r) - Expect(pw.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET /Users/Public", func() { - publicUsers := func() []dto.UserDto { - w := rawReq("GET", "/Users/Public", "") - Expect(w.Code).To(Equal(http.StatusOK)) - var users []dto.UserDto - parseInto(w, &users) - return users - } - - It("returns an empty list when no users are exposed", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ExposedPublicUsers = "" - Expect(publicUsers()).To(BeEmpty()) - }) - - It("lists the configured users to an unauthenticated caller, without policy", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ExposedPublicUsers = "regular" - users := publicUsers() - Expect(users).To(HaveLen(1)) - Expect(users[0].Name).To(Equal("regular")) - Expect(users[0].Id).To(Equal(enc(testID("regular-1")))) - Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login - }) - }) - - Describe("current user", func() { - It("returns the caller from GET /Users/Me", func() { - var u dto.UserDto - parseInto(getAs(regularUser, "/Users/Me"), &u) - Expect(u.Name).To(Equal("regular")) - Expect(u.Id).To(Equal(enc(testID("regular-1")))) - }) - - It("returns the caller from GET /Users/{userId}", func() { - var u dto.UserDto - parseInto(get("/Users/admin-1"), &u) - Expect(u.Name).To(Equal("admin")) - }) - }) - - Describe("auth enforcement", func() { - It("rejects a protected request with no token", func() { - Expect(rawReq("GET", "/Users/Me", "").Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects a protected request with a bogus token", func() { - r := httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Token", "not-a-valid-jwt") - w := httptest.NewRecorder() - router.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) -}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go deleted file mode 100644 index 68d87cd25..000000000 --- a/server/jellyfin/e2e/browsing_test.go +++ /dev/null @@ -1,701 +0,0 @@ -package e2e - -import ( - "encoding/json" - "net/http" - "slices" - "sort" - "time" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func names(items []dto.BaseItemDto) []string { - out := make([]string, len(items)) - for i, it := range items { - out[i] = it.Name - } - return out -} - -var _ = Describe("Browsing", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("GET /UserViews", func() { - It("returns the user's libraries as CollectionFolders", func() { - q := queryResult(get("/UserViews")) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("Music Library")) - Expect(q.Items[0].Type).To(Equal("CollectionFolder")) - Expect(q.Items[0].CollectionType).To(Equal("music")) - }) - }) - - Describe("GET /Items by type", func() { - It("lists all albums", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(5)) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) - }) - - It("lists all songs with Audio type and an AlbumId", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(7)) - for _, it := range q.Items { - Expect(it.Type).To(Equal("Audio")) - Expect(it.MediaType).To(Equal("Audio")) - Expect(it.LocationType).To(Equal("FileSystem")) - Expect(it.ServerId).To(MatchRegexp("^[0-9a-f]{32}$")) // real Jellyfin always sets it, no-dash GUID form - Expect(it.AlbumId).ToNot(BeEmpty()) - } - }) - - // Real Jellyfin omits MediaSources from a plain list response, returning it only when the - // client asks via Fields=MediaSources (Finamp's download dialog does). - It("omits MediaSources unless Fields=MediaSources is requested", func() { - plain := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) - for _, it := range plain.Items { - Expect(it.MediaSources).To(BeEmpty()) - } - withSources := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=MediaSources")) - for _, it := range withSources.Items { - Expect(it.MediaSources).To(HaveLen(1)) - } - }) - - // Clients (Finamp, Feishin) send Fields as repeated params rather than one comma-separated - // value; real Jellyfin accepts both, so a later Fields=MediaSources must still take effect. - It("honors MediaSources when Fields is sent as repeated params", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=Genres&Fields=MediaSources")) - Expect(q.Items).ToNot(BeEmpty()) - for _, it := range q.Items { - Expect(it.MediaSources).To(HaveLen(1)) - } - }) - - It("lists all album artists", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(4)) - Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) - }) - - // Finamp's A-Z jump scans SortName client-side, so it must follow the response order. - It("returns artists' SortName matching the server sort order when Fields=SortName", func() { - plain := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName")) - for _, it := range plain.Items { - Expect(it.SortName).To(BeEmpty()) - } - q := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName&Fields=SortName")) - Expect(q.Items).ToNot(BeEmpty()) - sortNames := make([]string, 0, len(q.Items)) - for _, it := range q.Items { - Expect(it.SortName).ToNot(BeEmpty()) - sortNames = append(sortNames, it.SortName) - } - Expect(slices.IsSorted(sortNames)).To(BeTrue(), "SortName values must follow the response order: %v", sortNames) - // "The Beatles" must be filed under B, exposing the article-stripped key to clients. - Expect(sortNames).To(ContainElement("beatles")) - }) - - It("lists all genres", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(3)) - Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) - }) - - It("returns no playlists when none exist", func() { - q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(0)) - Expect(q.Items).To(BeEmpty()) - }) - - // Manet syncs collections as Boxset, and took albums coming back instead as a sync failure. - DescribeTable("returns nothing for a type it does not serve", - func(itemType string) { - q := queryResult(get("/Items?IncludeItemTypes=" + itemType + "&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(0)) - Expect(q.Items).To(BeEmpty()) - }, - Entry("a Jellyfin kind Navidrome has none of", "Boxset"), - ) - - It("treats a name Jellyfin does not know as an absent IncludeItemTypes", func() { - q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(queryResult(get("/Items?Recursive=true")).TotalRecordCount)) - Expect(q.TotalRecordCount).To(BeNumerically(">", 0)) - }) - - // A strict client (Manet) fails its whole sync on the first item missing any of these. - DescribeTable("sends the keys Jellyfin puts on every item", - func(itemType string, fields string, nonNull ...string) { - var body struct{ Items []map[string]json.RawMessage } - res := get("/Items?IncludeItemTypes=" + itemType + "&Fields=" + fields + "&Recursive=true") - Expect(json.Unmarshal(res.Body.Bytes(), &body)).To(Succeed()) - Expect(body.Items).ToNot(BeEmpty()) - for _, it := range body.Items { - Expect(it).To(HaveKey("ChannelId"), "ChannelId is null, but always present") - for _, k := range nonNull { - Expect(it).To(HaveKey(k)) - Expect(string(it[k])).ToNot(Equal("null"), k) - } - } - }, - Entry("songs", "Audio", "Genres,Tags", "ImageTags", "HasLyrics", "Genres", "GenreItems", "Tags"), - Entry("albums", "MusicAlbum", "Genres", "ImageTags", "Genres", "GenreItems"), - Entry("artists", "MusicArtist", "Genres", "ImageTags", "Genres", "GenreItems"), - ) - - It("sends MediaType Unknown on items without one, as Jellyfin always emits it", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(q.Items).ToNot(BeEmpty()) - for _, it := range q.Items { - Expect(it.MediaType).To(Equal("Unknown")) - } - Expect(queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")).Items[0].MediaType).To(Equal("Audio")) - }) - - }) - - Describe("ParentId browsing", func() { - It("browses an artist's albums", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) - }) - - It("browses an album's tracks in track order by default", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")))) - Expect(q.TotalRecordCount).To(Equal(2)) - // Track order (Something=1, Come Together=2) differs from alphabetical title order, - // proving the sort is by track number, not name. - Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) - Expect(*q.Items[0].IndexNumber).To(Equal(1)) - Expect(*q.Items[1].IndexNumber).To(Equal(2)) - }) - - // "Latest Releases": if PremiereDate isn't recognized, applySort falls through to album-name order. - It("sorts an artist's tracks by release year for SortBy=PremiereDate (Latest Releases)", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumArtistIds=" + enc(artistID("The Beatles")) + - "&SortBy=PremiereDate%2CAlbum%2CParentIndexNumber%2CIndexNumber%2CSortName&SortOrder=Descending")) - got := names(q.Items) - Expect(got).To(HaveLen(3)) - Expect(got[:2]).To(ConsistOf("Come Together", "Something")) - Expect(got[2]).To(Equal("Help!")) - }) - - It("respects Finamp's explicit ParentIndexNumber/IndexNumber SortBy on an album", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&SortBy=ParentIndexNumber,IndexNumber,SortName")) - Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) - }) - }) - - // Finamp's download sync asks a library for the tracks outside any album this way; answering - // with every track would stream the whole library. - Describe("Recursive=false", func() { - lib1 := dto.EncodeLibraryID(1) - - It("returns no songs for a library parent", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false")) - Expect(q.Items).To(BeEmpty()) - Expect(q.TotalRecordCount).To(BeZero()) - }) - - It("still lists the library's albums", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + lib1 + "&Recursive=false")) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) - }) - - It("still lists an album's tracks", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&Recursive=false")) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) - }) - }) - - // Finamp's artist screen sends ParentId= (scoping) plus AlbumArtistIds/ArtistIds - // for the actual artist filter, not ParentId=. - Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() { - lib1 := dto.EncodeLibraryID(1) - - It("filters albums by AlbumArtistIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) - }) - - It("filters songs by ArtistIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) - }) - - It("filters albums by a single-album artist", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&AlbumArtistIds=" + enc(artistID("Led Zeppelin")))) - Expect(names(q.Items)).To(ConsistOf("IV")) - }) - - It("filters songs by a single-track artist", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("Miles Davis")))) - Expect(names(q.Items)).To(ConsistOf("So What")) - }) - - // contributingArtistIds is Jellify's "Featured On" section: albums the artist only appears - // on, which must exclude their own discography (albums where they are the album artist). - It("lists Featured On albums (contributingArtistIds) a performer only guests on", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("Featured Guest")))) - Expect(names(q.Items)).To(ConsistOf("Singles")) - }) - - It("excludes an album artist's own discography from Featured On (contributingArtistIds)", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).ToNot(ContainElement("Abbey Road")) - Expect(names(q.Items)).ToNot(ContainElement("Help!")) - }) - }) - - // A malformed id must 404, not silently drop the filter and widen the query to the whole - // library; a well-formed but unknown one must still fail closed to zero results. - Describe("stale and malformed id filtering", func() { - lib1 := dto.EncodeLibraryID(1) - - It("404s a malformed ParentId instead of listing every song", func() { - w := get("/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns zero songs, not the whole library, for a well-formed but unknown ParentId", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(testID("no-such-album")))) - Expect(q.TotalRecordCount).To(BeZero()) - Expect(q.Items).To(BeEmpty()) - }) - - It("404s a malformed AlbumArtistIds instead of listing every album", func() { - w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns zero albums, not every album, for a well-formed but unknown AlbumArtistIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(testID("no-such-artist")))) - Expect(q.TotalRecordCount).To(BeZero()) - Expect(q.Items).To(BeEmpty()) - }) - - It("404s a malformed ArtistIds instead of listing every song", func() { - w := get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns zero songs, not every song, for a well-formed but unknown ArtistIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(testID("no-such-artist")))) - Expect(q.TotalRecordCount).To(BeZero()) - Expect(q.Items).To(BeEmpty()) - }) - - It("404s a malformed Ids entry instead of batch-fetching nothing", func() { - w := get("/Items?Ids=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s when only one of several Ids entries is malformed", func() { - w := get("/Items?Ids=" + enc(songID("Something")) + ",not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns zero items, not an error, for a well-formed but unknown Ids entry", func() { - q := queryResult(get("/Items?Ids=" + enc(testID("no-such-item")))) - Expect(q.TotalRecordCount).To(BeZero()) - Expect(q.Items).To(BeEmpty()) - }) - - It("404s a malformed GenreIds entry instead of listing every album", func() { - w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s when only one of several GenreIds entries is malformed", func() { - w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + ",not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s a malformed AlbumIds entry instead of listing every song", func() { - w := get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s a malformed StudioIds entry instead of listing every album", func() { - w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns zero albums, not every album, for a well-formed but unknown StudioIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + enc(testID("no-such-studio")))) - Expect(q.TotalRecordCount).To(BeZero()) - Expect(q.Items).To(BeEmpty()) - }) - }) - - // Feishin fetches an album's tracks with AlbumIds=&IncludeItemTypes=Audio&Recursive=true. - Describe("album filtering (AlbumIds)", func() { - It("filters songs by AlbumIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road")))) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) - Expect(q.TotalRecordCount).To(Equal(2)) - }) - - It("matches any of multiple comma-separated AlbumIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road")) + "," + enc(albumID("IV")))) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) - Expect(q.TotalRecordCount).To(Equal(3)) - }) - - It("returns nothing for an unknown album id", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(testID("no-such-album")))) - Expect(q.Items).To(BeEmpty()) - Expect(q.TotalRecordCount).To(Equal(0)) - }) - }) - - Describe("year filtering (Years=)", func() { - It("filters items by Years=", func() { - albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Years=1959")) - Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) - - songs := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Years=1959")) - for _, it := range songs.Items { - Expect(it.ProductionYear).ToNot(BeNil()) - Expect(*it.ProductionYear).To(Equal(1959)) - } - Expect(songs.Items).ToNot(BeEmpty()) - }) - }) - - Describe("studio filtering (StudioIds=)", func() { - It("filters items by StudioIds=", func() { - studios := queryResult(get("/Studios")) - var columbiaID string - for _, it := range studios.Items { - if it.Name == "Columbia" { - columbiaID = it.Id - } - } - Expect(columbiaID).ToNot(BeEmpty()) - - albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + columbiaID)) - Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) - }) - - It("returns filter lists scoped to a ParentId library", func() { - var filters dto.QueryFiltersLegacy - parseInto(get("/Items/Filters?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=true"), &filters) - Expect(filters.Years).To(ContainElements(1959, 1965)) - studios := queryResult(get("/Studios?ParentId=" + dto.EncodeLibraryID(1))) - Expect(names(studios.Items)).To(ContainElement("Columbia")) - }) - }) - - // Finamp's genre screen sends ParentId= (scoping) plus GenreIds=. - Describe("genre filtering (GenreIds)", func() { - lib1 := dto.EncodeLibraryID(1) - - It("filters albums by GenreIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) - Expect(names(q.Items)).To(ConsistOf("Kind of Blue")) - Expect(q.TotalRecordCount).To(Equal(1)) - }) - - It("filters songs by GenreIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Rock")))) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!", "Stairway To Heaven")) - Expect(q.TotalRecordCount).To(Equal(4)) - }) - - It("matches any of multiple comma-separated GenreIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) - Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) - }) - - It("matches any of multiple repeated GenreIds params (@jellyfin/sdk spelling)", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "&GenreIds=" + enc(genreID("Pop")))) - Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) - }) - - It("returns nothing for an unknown genre id", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(testID("no-such-genre")))) - Expect(q.Items).To(BeEmpty()) - Expect(q.TotalRecordCount).To(Equal(0)) - }) - - It("filters album artists by GenreIds on /Artists/AlbumArtists", func() { - q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) - Expect(names(q.Items)).To(ConsistOf("Miles Davis")) - Expect(q.TotalRecordCount).To(Equal(1)) - }) - - It("matches album artists of any of multiple GenreIds", func() { - q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) - Expect(names(q.Items)).To(ConsistOf("Miles Davis", "Solo Artist")) - }) - - It("filters album artists by GenreIds via /Items?IncludeItemTypes=MusicArtist", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true&GenreIds=" + enc(genreID("Rock")))) - Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin")) - }) - - It("returns no artists for an unknown genre id", func() { - q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(testID("no-such-genre")))) - Expect(q.Items).To(BeEmpty()) - }) - }) - - // Jellify (and the official Jellyfin TypeScript SDK) send query params in camelCase - // (parentId, includeItemTypes, albumArtistIds), where Finamp sends PascalCase. Real Jellyfin - // binds them case-insensitively; these guard that our dispatcher does too, and that browsing an - // album with only parentId (no IncludeItemTypes, as Jellify does) returns its tracks. - Describe("camelCase query params (Jellify / JS SDK)", func() { - lib1 := dto.EncodeLibraryID(1) - - It("filters albums by camelCase albumArtistIds", func() { - q := queryResult(get("/Items?includeItemTypes=MusicAlbum&recursive=true&parentId=" + lib1 + "&albumArtistIds=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) - }) - - It("filters songs by camelCase artistIds", func() { - q := queryResult(get("/Items?includeItemTypes=Audio&recursive=true&parentId=" + lib1 + "&artistIds=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) - }) - - It("browses an album's tracks with only camelCase parentId (no IncludeItemTypes)", func() { - q := queryResult(get("/Items?parentId=" + enc(albumID("Abbey Road")) + "&sortBy=ParentIndexNumber&sortBy=IndexNumber&sortBy=SortName")) - Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) - }) - - It("browses an artist's albums with only camelCase parentId (no IncludeItemTypes)", func() { - q := queryResult(get("/Items?parentId=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) - }) - }) - - Describe("search, batch and pagination", func() { - It("searches albums by term", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey")) - Expect(names(q.Items)).To(ContainElement("Abbey Road")) - }) - - It("batch-fetches specific items by Ids", func() { - ids := enc(albumID("Abbey Road")) + "," + enc(albumID("IV")) - q := queryResult(get("/Items?ids=" + ids)) - Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "IV")) - }) - - It("applies Limit while reporting the full TotalRecordCount", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Limit=2")) - Expect(q.Items).To(HaveLen(2)) - Expect(q.TotalRecordCount).To(Equal(5)) - }) - - It("pages distinct items via StartIndex", func() { - p1 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=0")) - p2 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=2")) - Expect(p1.Items).To(HaveLen(2)) - Expect(p2.Items).To(HaveLen(2)) - Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) - }) - - It("merges multiple types into one paginated result", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(12)) // 5 albums + 7 songs - }) - - // Chaining the per-type cursors must preserve the merged order. - It("streams an unbounded multi-type merge, honoring StartIndex", func() { - all := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) - Expect(all.Items).To(HaveLen(12)) - - skipped := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true&StartIndex=2")) - Expect(skipped.Items).To(HaveLen(10)) - Expect(skipped.TotalRecordCount).To(Equal(12)) - Expect(skipped.StartIndex).To(Equal(2)) - Expect(names(skipped.Items)).To(Equal(names(all.Items)[2:])) - }) - - // Paging must ride on the cursor query's LIMIT/OFFSET, not be applied after materializing. - It("pages songs via StartIndex/Limit while reporting the full total", func() { - all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName")) - Expect(all.TotalRecordCount).To(Equal(7)) - Expect(all.Items).To(HaveLen(7)) - - p1 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=0")) - p2 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=3")) - Expect(p1.Items).To(HaveLen(3)) - Expect(p2.Items).To(HaveLen(3)) - Expect(p1.TotalRecordCount).To(Equal(7)) - // The two pages are distinct and match the head of the unpaged, identically-sorted list. - Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) - Expect(append(names(p1.Items), names(p2.Items)...)).To(Equal(names(all.Items)[:6])) - }) - }) - - Describe("GET /Items/{id}", func() { - It("resolves an album", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(albumID("Kind of Blue"))), &item) - Expect(item.Name).To(Equal("Kind of Blue")) - Expect(item.Type).To(Equal("MusicAlbum")) - }) - - It("resolves a song", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("So What"))), &item) - Expect(item.Type).To(Equal("Audio")) - }) - - It("includes a parseable DateCreated (Date Added) on a song", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("So What"))), &item) - Expect(item.DateCreated).ToNot(BeEmpty()) - _, err := time.Parse(time.RFC3339, item.DateCreated) - Expect(err).ToNot(HaveOccurred()) - }) - - It("includes structured ArtistItems and AlbumArtists on a song (now-playing artist)", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("So What"))), &item) - Expect(item.ArtistItems).ToNot(BeEmpty()) - Expect(item.ArtistItems[0].Name).To(Equal("Miles Davis")) - Expect(item.ArtistItems[0].Id).ToNot(BeEmpty()) - Expect(item.AlbumArtists).ToNot(BeEmpty()) - Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis")) - }) - - It("exposes NormalizationGain and AlbumNormalizationGain from ReplayGain tags", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &item) - Expect(item.NormalizationGain).ToNot(BeNil()) - Expect(*item.NormalizationGain).To(BeNumerically("~", -3.5, 0.001)) - Expect(item.AlbumNormalizationGain).ToNot(BeNil()) - Expect(*item.AlbumNormalizationGain).To(BeNumerically("~", -4.25, 0.001)) - }) - - It("omits normalization gains for files without ReplayGain tags", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("So What"))), &item) - Expect(item.NormalizationGain).To(BeNil()) - Expect(item.AlbumNormalizationGain).To(BeNil()) - }) - - It("resolves an artist", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item) - Expect(item.Type).To(Equal("MusicArtist")) - }) - - It("returns 404 for an unknown id", func() { - Expect(get("/Items/" + enc(testID("does-not-exist"))).Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("GET /Users/{userId}/Items/Latest", func() { - It("returns recent albums as a bare array, respecting Limit", func() { - var items []dto.BaseItemDto - parseInto(get("/Users/admin-1/Items/Latest?Limit=3"), &items) - Expect(items).To(HaveLen(3)) - for _, it := range items { - Expect(it.Type).To(Equal("MusicAlbum")) - } - }) - }) - - Describe("GET /Items/Latest", func() { - // Jellyfin marks the /Users/{userId} form obsolete and hides it from the OpenAPI spec, so - // SDK-generated clients (Jellify) only ever call this one. - It("serves the same response as the legacy /Users/{userId} route", func() { - Expect(get("/Items/Latest?Limit=3").Body.String()). - To(Equal(get("/Users/admin-1/Items/Latest?Limit=3").Body.String())) - }) - - It("scopes to ParentId when it names a library", func() { - var items []dto.BaseItemDto - parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(1)), &items) - Expect(names(items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) - }) - - It("scopes to ParentId when it names an artist", func() { - var items []dto.BaseItemDto - parseInto(get("/Items/Latest?ParentId="+enc(artistID("The Beatles"))), &items) - Expect(names(items)).To(ConsistOf("Abbey Road", "Help!")) - }) - - It("returns nothing for a library the user cannot access", func() { - var items []dto.BaseItemDto - parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(99)), &items) - Expect(items).To(BeEmpty()) - }) - - It("returns nothing for an id that is neither a library nor an artist", func() { - var items []dto.BaseItemDto - parseInto(get("/Items/Latest?ParentId="+enc(testID("does-not-exist"))), &items) - Expect(items).To(BeEmpty()) - }) - - It("404s a malformed ParentId, like every other filtered endpoint", func() { - Expect(get("/Items/Latest?ParentId=not-a-valid-id").Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("GET /Artists and /Genres", func() { - It("lists album artists only on /Artists/AlbumArtists (excludes performer-only artists)", func() { - names := names(queryResult(get("/Artists/AlbumArtists")).Items) - Expect(names).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) - Expect(names).ToNot(ContainElement("Featured Guest")) - }) - - It("lists performing artists on /Artists (includes a track's guest artist)", func() { - names := names(queryResult(get("/Artists")).Items) - Expect(names).To(ContainElement("Featured Guest")) - Expect(names).To(ContainElement("Solo Artist")) - }) - - It("returns different lists for album artists and performing artists", func() { - aa := names(queryResult(get("/Artists/AlbumArtists")).Items) - ar := names(queryResult(get("/Artists")).Items) - Expect(aa).ToNot(Equal(ar)) - }) - - It("lists genres", func() { - q := queryResult(get("/Genres")) - Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) - }) - - It("pages genres with StartIndex/Limit and still reports the full total", func() { - q := queryResult(get("/Genres?StartIndex=1&Limit=1")) - Expect(q.Items).To(HaveLen(1)) - Expect(q.TotalRecordCount).To(Equal(3)) - }) - - It("returns record labels as Studio items", func() { - q := queryResult(get("/Studios")) - names := make([]string, 0, len(q.Items)) - for _, it := range q.Items { - Expect(it.Type).To(Equal("Studio")) - names = append(names, it.Name) - } - Expect(names).To(ContainElement("Columbia")) - }) - }) - - Describe("GET /Items/Filters", func() { - It("returns legacy query filters with genres, years, and empty tags/ratings", func() { - var filters dto.QueryFiltersLegacy - parseInto(get("/Items/Filters?IncludeItemTypes=Audio&Recursive=true"), &filters) - Expect(filters.Genres).To(ContainElements("Rock", "Jazz")) - Expect(filters.Years).To(ContainElements(1959, 1965, 1969, 1971)) - // Verify ascending sort by checking it equals itself sorted. - sorted := make([]int, len(filters.Years)) - copy(sorted, filters.Years) - sort.Ints(sorted) - Expect(filters.Years).To(Equal(sorted)) - Expect(filters.Tags).To(BeEmpty()) - Expect(filters.OfficialRatings).To(BeEmpty()) - }) - }) -}) diff --git a/server/jellyfin/e2e/discovery_test.go b/server/jellyfin/e2e/discovery_test.go deleted file mode 100644 index 0126ef3c6..000000000 --- a/server/jellyfin/e2e/discovery_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package e2e - -import ( - "context" - "encoding/json" - "net" - "time" - - "github.com/navidrome/navidrome/server/jellyfin" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Auto discovery", func() { - BeforeEach(func() { setupTestDB() }) - - It("advertises the same Id and Name as /System/Info/Public", func() { - server, err := net.ListenPacket("udp4", "127.0.0.1:0") - Expect(err).ToNot(HaveOccurred()) - client, err := net.ListenPacket("udp4", "127.0.0.1:0") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(client.Close) - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - defer close(done) - jellyfin.NewDiscovery(ds).ServeOn(ctx, server) - }() - DeferCleanup(func() { - cancel() - Eventually(done).Should(BeClosed()) - }) - - _, err = client.WriteTo([]byte("who is JellyfinServer?"), server.LocalAddr()) - Expect(err).ToNot(HaveOccurred()) - Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed()) - buf := make([]byte, 1024) - n, _, err := client.ReadFrom(buf) - Expect(err).ToNot(HaveOccurred()) - var reply map[string]any - Expect(json.Unmarshal(buf[:n], &reply)).To(Succeed()) - - var pub map[string]any - parseInto(rawReq("GET", "/System/Info/Public", ""), &pub) - Expect(reply["Id"]).To(Equal(pub["Id"])) - Expect(reply["Name"]).To(Equal(pub["ServerName"])) - Expect(reply["Address"]).To(HaveSuffix("/jellyfin")) - }) -}) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go deleted file mode 100644 index 4f3cd82b5..000000000 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ /dev/null @@ -1,437 +0,0 @@ -// Package e2e provides end-to-end integration tests for the Navidrome Jellyfin API. -// -// These tests exercise the full HTTP request/response cycle through the Jellyfin API router, -// using a real SQLite database and real repository implementations while stubbing out external -// services (artwork, streaming, transcoding) with spy/noop implementations. -// -// The harness mirrors server/subsonic/e2e (the Subsonic suite): BeforeSuite creates a temporary SQLite -// database, seeds two users (admin + regular) and one library backed by a fake in-memory -// filesystem, runs the scanner, and snapshots the golden DB. Each top-level Describe restores -// that snapshot and builds a fresh jellyfin.Router. -// -// # Seeded library (see buildTestFS) -// -// Rock/The Beatles/Abbey Road/ 01 Something (1969), 02 Come Together (1969) -// Rock/The Beatles/Help!/ 01 Help! (1965) -// Rock/Led Zeppelin/IV/ 01 Stairway To Heaven (1971) -// Jazz/Miles Davis/Kind of Blue/01 So What (1959) -// Pop/Solo Artist/Singles/ 01 Standalone Track (2020), 02 Duet (artist "Featured Guest") -// -// Totals: 7 songs, 5 albums, 4 album artists (+ 1 performer-only "Featured Guest" = 5 artists), -// 3 genres (Rock=4, Jazz=1, Pop=2). -package e2e - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/lyrics" - "github.com/navidrome/navidrome/core/matcher" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/sonic" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/db" - "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/persistence" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/tests/harness" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestJellyfinE2E(t *testing.T) { - tests.Init(t, false) - defer db.Close(t.Context()) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Jellyfin API E2E Suite") -} - -// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same -// id shape production uses. -func testID(label string) string { return id.NewHash("jellyfin-test", label) } - -// Easy aliases for the storagetest package -type _t = map[string]any - -var ( - template = storagetest.Template - track = storagetest.Track -) - -// Shared test state -var ( - ctx context.Context - ds *tests.MockDataStore - router http.Handler - streamerSpy *harness.SpyStreamer - artworkSpy *spyArtwork - providerFake *fakeExternalProvider - sonicProviderFake *fakeSonicProvider - goldenDB *harness.DB - dataFolder string - - adminUser = model.User{ - ID: testID("admin-1"), - UserName: "admin", - Name: "Admin User", - IsAdmin: true, - } - - regularUser = model.User{ - ID: testID("regular-1"), - UserName: "regular", - Name: "Regular User", - IsAdmin: false, - } -) - -// buildTestFS creates the seeded test filesystem (see package doc for totals). -func buildTestFS() storagetest.FakeFS { - abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) - help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) - ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) - kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz", "label": "Columbia"}) - singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) - - return harness.CreateFS(fstest.MapFS{ - // Track numbers are deliberately reversed vs. alphabetical title order (Something=1, - // Come Together=2) so tests can tell track-order sorting apart from title sorting. - "Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")), - "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), - "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), - "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{ - "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", - "replaygain_track_gain": "-3.50 dB", - "replaygain_album_gain": "-4.25 dB", - })), - "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), - "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), - // "Featured Guest" is the track artist here (album artist stays "Solo Artist"), so it's a - // performer but not an album artist — lets tests tell /Artists from /Artists/AlbumArtists. - "Pop/Solo Artist/Singles/02 - Duet.mp3": singles(track(2, "Duet", _t{"artist": "Featured Guest"})), - }) -} - -// --- Request helpers --- - -// jReq performs a full HTTP round-trip as the given user (token auth) and returns the recorder. -func jReq(user model.User, method, path, body string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - var reader io.Reader - if body != "" { - reader = strings.NewReader(body) - } - r := httptest.NewRequest(method, path, reader) - token, err := auth.CreateToken(&user) - Expect(err).ToNot(HaveOccurred()) - r.Header.Set("X-Emby-Token", token) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) - if body != "" { - r.Header.Set("Content-Type", "application/json") - } - router.ServeHTTP(w, r) - return w -} - -// rawReq performs a request with no authentication (for public routes). -func rawReq(method, path, body string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - var reader io.Reader - if body != "" { - reader = strings.NewReader(body) - } - r := httptest.NewRequest(method, path, reader) - if body != "" { - r.Header.Set("Content-Type", "application/json") - } - router.ServeHTTP(w, r) - return w -} - -func get(path string) *httptest.ResponseRecorder { return jReq(adminUser, "GET", path, "") } -func getAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "GET", path, "") } -func post(path, body string) *httptest.ResponseRecorder { return jReq(adminUser, "POST", path, body) } -func postAs(u model.User, path, body string) *httptest.ResponseRecorder { - return jReq(u, "POST", path, body) -} -func del(path string) *httptest.ResponseRecorder { return jReq(adminUser, "DELETE", path, "") } -func delAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "DELETE", path, "") } - -// upload performs an authenticated POST with a custom Content-Type and raw body (image upload). -func upload(user model.User, path, contentType string, body []byte) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", path, bytes.NewReader(body)) - token, err := auth.CreateToken(&user) - Expect(err).ToNot(HaveOccurred()) - r.Header.Set("X-Emby-Token", token) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) - r.Header.Set("Content-Type", contentType) - router.ServeHTTP(w, r) - return w -} - -// parseInto asserts a 200 and unmarshals the JSON body into target. -func parseInto(w *httptest.ResponseRecorder, target any) { - Expect(w.Code).To(Equal(http.StatusOK), "body: %s", w.Body.String()) - Expect(json.Unmarshal(w.Body.Bytes(), target)).To(Succeed()) -} - -// queryResult asserts a 200 and returns the parsed QueryResult. -func queryResult(w *httptest.ResponseRecorder) dto.QueryResult { - var q dto.QueryResult - parseInto(w, &q) - return q -} - -// createPlaylist creates a playlist as admin (encodedIds are the Jellyfin-encoded item ids a -// client would send) and returns its decoded Navidrome id. -func createPlaylist(name string, encodedIds []string) string { - return createPlaylistAs(adminUser, name, encodedIds...) -} - -// createPlaylistAs creates a playlist owned by the given user and returns its decoded id. -func createPlaylistAs(user model.User, name string, encodedIds ...string) string { - if encodedIds == nil { - encodedIds = []string{} - } - body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds}) - Expect(err).ToNot(HaveOccurred()) - return createPlaylistBodyAs(user, string(body)) -} - -// createPlaylistBodyAs posts a raw create body, for tests that need fields the helpers above don't -// build, and returns the new playlist's decoded id. -func createPlaylistBodyAs(user model.User, body string) string { - var res map[string]string - parseInto(postAs(user, "/Playlists", body), &res) - Expect(res["Id"]).ToNot(BeEmpty()) - id, ok := dto.DecodeID(res["Id"]) - Expect(ok).To(BeTrue()) - return id -} - -// --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) --- - -func enc(id string) string { return dto.EncodeID(id) } - -// The seeded library is tiny, so the id lookups fetch-all and match by name in Go rather than -// guessing repository filter column names. - -func albumID(name string) string { - albums, err := ds.Album().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - for _, a := range albums { - if a.Name == name { - return a.ID - } - } - Fail("album not found: " + name) - return "" -} - -func songID(title string) string { - mfs, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - for _, mf := range mfs { - if mf.Title == title { - return mf.ID - } - } - Fail("song not found: " + title) - return "" -} - -func artistID(name string) string { - artists, err := ds.Artist().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - for _, a := range artists { - if a.Name == name { - return a.ID - } - } - Fail("artist not found: " + name) - return "" -} - -func genreID(name string) string { - genres, err := ds.Genre().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - for _, g := range genres { - if g.Name == name { - return g.ID - } - } - Fail("genre not found: " + name) - return "" -} - -// --- Suite lifecycle --- - -var _ = BeforeSuite(func() { - ctx = request.WithUser(GinkgoT().Context(), adminUser) - dataFolder = filepath.Join(GinkgoT().TempDir(), "data") - Expect(os.MkdirAll(dataFolder, 0o755)).To(Succeed()) - - conf.Server.MusicFolder = "fake:///music" - conf.Server.DataFolder = conf.NewDir(dataFolder) - conf.Server.DevExternalScanner = false - - buildTestFS() - goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) - ctx = request.WithUser(GinkgoT().Context(), adminUser) -}) - -var _ = AfterSuite(func() { - db.Close(ctx) -}) - -// setupTestDB restores the golden snapshot and builds a fresh jellyfin.Router. Call from -// BeforeEach in each test container. -func setupTestDB() { - ctx = request.WithUser(GinkgoT().Context(), adminUser) - - DeferCleanup(configtest.SetupConfig()) - conf.Server.MusicFolder = "fake:///music" - conf.Server.DataFolder = conf.NewDir(dataFolder) - conf.Server.DevExternalScanner = false - conf.Server.DevEnableMediaFileProbe = false - - goldenDB.Restore() - - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(ds) - - streamerSpy = &harness.SpyStreamer{} - artworkSpy = &spyArtwork{} - providerFake = &fakeExternalProvider{} - sonicProviderFake = &fakeSonicProvider{} - sonicSvc := sonic.New(ds, &fakeSonicLoader{provider: sonicProviderFake}, matcher.New(ds)) - decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) - router = jellyfin.New( - ds, - artworkSpy, - streamerSpy, - decider, - core.NewPlayers(ds), - scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), - providerFake, - sonicSvc, - lyrics.NewLyrics(ds, nil), - events.NoopBroker(), - quickconnect.New(), - ) -} - -// fakeExternalProvider is a configurable stand-in for external.Provider. Tests set the return -// values they need; unset fields yield empty similar lists. Only the methods the Jellyfin API uses -// are overridden — the embedded interface panics for anything else, flagging unexpected calls. -type fakeExternalProvider struct { - external.Provider - similarArtists model.Artists - similarSongs model.MediaFiles -} - -func (f *fakeExternalProvider) UpdateArtistInfo(_ context.Context, id string, _ int, _ bool) (*model.Artist, error) { - return &model.Artist{ID: id, SimilarArtists: f.similarArtists}, nil -} - -func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { - return f.similarSongs, nil -} - -// fakeSonicLoader always advertises a SonicSimilarity provider so the AudioMuse endpoints are -// active in e2e; the provider it hands back returns test-configured results. -type fakeSonicLoader struct{ provider sonic.Provider } - -func (f *fakeSonicLoader) PluginNames(capability string) []string { - if capability == "SonicSimilarity" { - return []string{"fake"} - } - return nil -} - -func (f *fakeSonicLoader) LoadSonicSimilarity(string) (sonic.Provider, bool) { - return f.provider, true -} - -// fakeSonicProvider is a configurable stand-in for a sonic-similarity plugin. Tests set the -// agents.Song results; the real matcher resolves them back to seeded library tracks. -type fakeSonicProvider struct { - similar []sonic.SimilarResult - path []sonic.SimilarResult -} - -func (f *fakeSonicProvider) GetSonicSimilarTracks(context.Context, *model.MediaFile, int) ([]sonic.SimilarResult, error) { - return f.similar, nil -} - -func (f *fakeSonicProvider) FindSonicPath(context.Context, *model.MediaFile, *model.MediaFile, int) ([]sonic.SimilarResult, error) { - return f.path, nil -} - -// songAgent looks a seeded track up by title (titles are unique in the seed) and builds an -// agents.Song carrying its title+artist, so the matcher resolves it back to that MediaFile. -func songAgent(title string) agents.Song { - mfs, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - for _, mf := range mfs { - if mf.Title == title { - return agents.Song{Name: mf.Title, Artists: []agents.Artist{{Name: mf.Artist}}} - } - } - Fail("song not found: " + title) - return agents.Song{} -} - -// --- Spy/noop dependencies (shared ones live in tests/harness) --- - -// spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the -// resolved ArtworkID and that resolution runs under an elevated (admin) context. -type spyArtwork struct { - lastID string - lastCtx context.Context - data []byte -} - -func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { - return nil, model.ErrNotFound -} - -func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (*artwork.Image, error) { - s.lastID = id - s.lastCtx = c - d := s.data - if d == nil { - d = []byte("IMG") - } - return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(d))}, nil -} - -var _ artwork.Artwork = &spyArtwork{} diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go deleted file mode 100644 index d1c3c132b..000000000 --- a/server/jellyfin/e2e/images_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// The image endpoint is public and resolves artwork under an elevated (admin) context. The suite -// wires a spyArtwork that captures the resolved ArtworkID and the context, so these tests assert -// resolution and elevation without needing real image processing. -var _ = Describe("Item images", func() { - BeforeEach(func() { setupTestDB() }) - - It("resolves an album's Primary image", func() { - id := albumID("Abbey Road") - w := get("/Items/" + enc(id) + "/Images/Primary") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(Equal("IMG")) - Expect(artworkSpy.lastID).To(ContainSubstring(id)) - }) - - It("resolves an artist's Primary image", func() { - id := artistID("Miles Davis") - Expect(get("/Items/" + enc(id) + "/Images/Primary").Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).To(ContainSubstring(id)) - }) - - It("resolves a private playlist's cover under an elevated context", func() { - // The route carries no user in ctx (public); resolution runs elevated so the visibility - // filter doesn't eat the cover. - plID := createPlaylist("Private Mix", nil) - w := get("/Items/" + enc(plID) + "/Images/Primary") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).To(ContainSubstring(plID)) - - u, ok := request.UserFrom(artworkSpy.lastCtx) - Expect(ok).To(BeTrue()) - Expect(u.IsAdmin).To(BeTrue()) - }) - - It("answers HEAD without authentication", func() { - Expect(rawReq("HEAD", "/Items/"+enc(albumID("Abbey Road"))+"/Images/Primary", "").Code).To(Equal(http.StatusOK)) - }) - - It("serves images without authentication (public route)", func() { - id := albumID("IV") - w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(Equal("IMG")) - }) - - // Covers are served regardless of playlist visibility — see getItemImage for the rationale. - It("resolves a private playlist's cover for an unauthenticated caller", func() { - plID := createPlaylist("Secret Mix", nil) // owned by admin, private - w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).To(ContainSubstring(plID)) - }) -}) diff --git a/server/jellyfin/e2e/lyrics_test.go b/server/jellyfin/e2e/lyrics_test.go deleted file mode 100644 index d52a3b00f..000000000 --- a/server/jellyfin/e2e/lyrics_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Lyrics", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("PlaybackInfo", func() { - It("advertises a Lyric stream for a track with embedded lyrics", func() { - id := songID("Stairway To Heaven") - var info dto.PlaybackInfoResponse - parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) - var found bool - for _, s := range info.MediaSources[0].MediaStreams { - if s.Type == "Lyric" { - found = true - } - } - Expect(found).To(BeTrue()) - }) - - It("does not advertise a Lyric stream for a track without lyrics", func() { - id := songID("So What") - var info dto.PlaybackInfoResponse - parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) - for _, s := range info.MediaSources[0].MediaStreams { - Expect(s.Type).ToNot(Equal("Lyric")) - } - }) - }) - - Describe("GET /Audio/{id}/Lyrics", func() { - It("returns the LyricDto for a track with embedded synced lyrics", func() { - id := songID("Stairway To Heaven") - var lyrics dto.LyricDto - parseInto(get("/Audio/"+enc(id)+"/Lyrics"), &lyrics) - Expect(lyrics.Lyrics).To(HaveLen(2)) - Expect(lyrics.Lyrics[0].Text).To(Equal("There's a lady who's sure")) - Expect(lyrics.Lyrics[0].Start).ToNot(BeNil()) - Expect(*lyrics.Lyrics[0].Start).To(Equal(int64(10000000))) - Expect(lyrics.Metadata.IsSynced).To(BeTrue()) - }) - - It("returns 404 for a track without lyrics", func() { - id := songID("So What") - Expect(get("/Audio/" + enc(id) + "/Lyrics").Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 for a fabricated id", func() { - Expect(get("/Audio/" + enc(testID("nope")) + "/Lyrics").Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("HasLyrics badge", func() { - It("is true for a track with embedded lyrics and false otherwise", func() { - var stairway dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &stairway) - Expect(*stairway.HasLyrics).To(BeTrue()) - - var soWhat dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("So What"))), &soWhat) - Expect(*soWhat.HasLyrics).To(BeFalse()) - }) - }) -}) diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go deleted file mode 100644 index 905d4e16f..000000000 --- a/server/jellyfin/e2e/multiuser_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Multi-user access control", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("library scoping", func() { - It("lets a library member browse its content", func() { - q := queryResult(getAs(regularUser, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(5)) - }) - - It("hides all content from a user with no library access", func() { - noAccess := model.User{ID: testID("noaccess-1"), UserName: "noaccess", Name: "No Access", NewPassword: "password"} - Expect(ds.User().Put(ctx, &noAccess)).To(Succeed()) - loaded, err := ds.User().FindByUsername(ctx, "noaccess") - Expect(err).ToNot(HaveOccurred()) - - q := queryResult(getAs(*loaded, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(0)) - }) - }) - - Describe("private playlists", func() { - It("does not expose another user's private playlist", func() { - adminPl := createPlaylist("Admin Private", nil) - - // Owner sees it. - Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) - // A different user does not. - Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) - // And can't read its items. - Expect(getAs(regularUser, "/Playlists/"+enc(adminPl)+"/Items").Code).To(Equal(http.StatusNotFound)) - }) - - It("does not let a non-owner delete another user's private playlist", func() { - adminPl := createPlaylist("Admin Private", nil) - // The playlist is invisible to the regular user, so delete resolves to 404 (not 403) — - // the API never reveals that someone else's private playlist exists. - Expect(delAs(regularUser, "/Items/"+enc(adminPl)).Code).To(Equal(http.StatusNotFound)) - // Still present for the owner. - Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) - }) - - It("does not let a non-owner annotate another user's private playlist", func() { - adminPl := createPlaylist("Admin Private", nil) - Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound)) - Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound)) - }) - - It("lets each user manage their own playlist", func() { - regularPl := createPlaylistAs(regularUser, "Regular's Mix") - Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) - Expect(delAs(regularUser, "/Items/"+enc(regularPl)).Code).To(Equal(http.StatusNoContent)) - }) - }) -}) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go deleted file mode 100644 index 49c8e5a8c..000000000 --- a/server/jellyfin/e2e/playlists_test.go +++ /dev/null @@ -1,450 +0,0 @@ -package e2e - -import ( - "bytes" - "image" - jpeglib "image/jpeg" - "net/http" - "os" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Playlists", func() { - BeforeEach(func() { setupTestDB() }) - - playlistItems := func(plID string) dto.QueryResult { - return queryResult(get("/Playlists/" + enc(plID) + "/Items")) - } - order := func(plID string) []string { return names(playlistItems(plID).Items) } - - createWith := func(body string) string { return createPlaylistBodyAs(adminUser, body) } - openAccess := func(plID string) bool { - var info dto.PlaylistInfo - parseInto(get("/Playlists/"+enc(plID)), &info) - return info.OpenAccess - } - - Describe("create", func() { - It("creates an empty playlist", func() { - plID := createPlaylist("Empty", nil) - var info dto.PlaylistInfo - parseInto(get("/Playlists/"+enc(plID)), &info) - Expect(info.OpenAccess).To(BeFalse(), "a playlist created without IsPublic stays private") - Expect(info.Shares).To(BeEmpty()) - Expect(info.ItemIds).To(BeEmpty()) - }) - - It("creates a playlist from song ids", func() { - plID := createPlaylist("Songs", []string{enc(songID("Come Together")), enc(songID("So What"))}) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) - }) - - It("expands an album id into its tracks", func() { - plID := createPlaylist("From Album", []string{enc(albumID("Abbey Road"))}) - q := playlistItems(plID) - Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) - }) - - It("expands an artist id into its tracks", func() { - plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))}) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1) - }) - - It("creates a public playlist when the client sends IsPublic true", func() { - Expect(openAccess(createWith(`{"Name":"Public","Ids":[],"IsPublic":true}`))).To(BeTrue()) - }) - - It("creates a private playlist when the client sends IsPublic false", func() { - Expect(openAccess(createWith(`{"Name":"Private","Ids":[],"IsPublic":false}`))).To(BeFalse()) - }) - - // dto.DecodeIDs is all-or-nothing: a malformed entry must 404 the whole request, not get - // dropped while the well-formed entries are still used to create a playlist. - It("404s when one of the Ids is malformed, without creating a playlist", func() { - before, err := ds.Playlist().CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - - body := `{"Name":"ShouldNotExist","Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}` - Expect(post("/Playlists", body).Code).To(Equal(http.StatusNotFound)) - - after, err := ds.Playlist().CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(after).To(Equal(before)) - }) - }) - - Describe("items", func() { - It("tags each entry with a PlaylistItemId", func() { - plID := createPlaylist("Tagged", []string{enc(songID("Help!"))}) - q := playlistItems(plID) - Expect(q.Items).To(HaveLen(1)) - Expect(q.Items[0].Type).To(Equal("Audio")) - Expect(q.Items[0].PlaylistItemId).ToNot(BeEmpty()) - }) - }) - - Describe("add and remove", func() { - It("adds a song by id", func() { - plID := createPlaylist("Add", nil) - Expect(post("/Playlists/"+enc(plID)+"/Items?ids="+enc(songID("So What")), "").Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) - }) - - It("adds an album (expanding to its tracks)", func() { - plID := createPlaylist("AddAlbum", []string{enc(songID("So What"))}) - post("/Playlists/"+enc(plID)+"/Items?ids="+enc(albumID("Abbey Road")), "") - Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // 1 + Abbey Road (2) - }) - - // Jellify's @jellyfin/sdk serializes id arrays as repeated params (ids=X&ids=Y), not a - // comma-joined value; all ids must be added, not just the first. - It("adds multiple songs sent as repeated ids params", func() { - plID := createPlaylist("Multi", nil) - url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + - "&ids=" + enc(songID("Come Together")) + "&ids=" + enc(songID("Help!")) - Expect(post(url, "").Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) - }) - - It("404s when one of the ids to add is malformed, without adding any track", func() { - plID := createPlaylist("AddMalformed", nil) - url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + ",not-a-valid-id" - Expect(post(url, "").Code).To(Equal(http.StatusNotFound)) - Expect(playlistItems(plID).TotalRecordCount).To(BeZero()) - }) - - Describe("at a position", func() { - var plID string - addAt := func(position string, ids ...string) { - url := "/Playlists/" + enc(plID) + "/Items?position=" + position - for _, id := range ids { - url += "&ids=" + enc(id) - } - Expect(post(url, "").Code).To(Equal(http.StatusNoContent)) - } - BeforeEach(func() { - plID = createPlaylist("Insert", []string{enc(songID("Come Together")), enc(songID("Something"))}) - }) - - DescribeTable("inserts at Jellyfin's zero-based position", - func(position string, want []string) { - addAt(position, songID("So What"), songID("Help!")) - Expect(order(plID)).To(Equal(want)) - }, - Entry("in the middle", "1", []string{"Come Together", "So What", "Help!", "Something"}), - Entry("first, for zero or less", "-3", []string{"So What", "Help!", "Come Together", "Something"}), - Entry("last, past the end", "9", []string{"Come Together", "Something", "So What", "Help!"}), - ) - - It("keeps an expanded album's track order", func() { - albumOrder := order(createPlaylist("Album", []string{enc(albumID("Abbey Road"))})) - addAt("1", albumID("Abbey Road")) - Expect(order(plID)).To(Equal(append(append([]string{"Come Together"}, albumOrder...), "Something"))) - }) - }) - - It("removes an entry by its PlaylistItemId", func() { - plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))}) - entryID := playlistItems(plID).Items[0].PlaylistItemId - Expect(del("/Playlists/" + enc(plID) + "/Items?entryIds=" + entryID).Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) - }) - - It("removes multiple entries sent as repeated entryIds params", func() { - plID := createPlaylist("MultiRemove", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))}) - items := playlistItems(plID).Items - url := "/Playlists/" + enc(plID) + "/Items?entryIds=" + items[0].PlaylistItemId + "&entryIds=" + items[1].PlaylistItemId - Expect(del(url).Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) - }) - }) - - Describe("move", func() { - move := func(u model.User, plID, entryID string, newIndex string) int { - return jReq(u, "POST", "/Playlists/"+enc(plID)+"/Items/"+entryID+"/Move/"+newIndex, "").Code - } - var plID string - var entries []dto.BaseItemDto - BeforeEach(func() { - plID = createPlaylist("Move", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))}) - entries = playlistItems(plID).Items - }) - - DescribeTable("moves an entry to Jellyfin's zero-based index", - func(entry int, newIndex string, want []string) { - Expect(move(adminUser, plID, entries[entry].PlaylistItemId, newIndex)).To(Equal(http.StatusNoContent)) - Expect(order(plID)).To(Equal(want)) - var positions []string - for _, it := range playlistItems(plID).Items { - positions = append(positions, it.PlaylistItemId) - } - Expect(positions).To(Equal([]string{dto.EncodePlaylistEntryID("1"), dto.EncodePlaylistEntryID("2"), dto.EncodePlaylistEntryID("3")})) - }, - Entry("towards the end", 0, "2", []string{"Something", "So What", "Come Together"}), - Entry("towards the start", 2, "0", []string{"So What", "Come Together", "Something"}), - Entry("to the end, past the last index", 0, "99", []string{"Something", "So What", "Come Together"}), - Entry("to the end, for the largest int", 0, "9223372036854775807", []string{"Something", "So What", "Come Together"}), - ) - - It("ignores an entry that is not in the playlist", func() { - Expect(move(adminUser, plID, dto.EncodePlaylistEntryID("42"), "0")).To(Equal(http.StatusNoContent)) - Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) - }) - - It("404s on a malformed entry id", func() { - Expect(move(adminUser, plID, enc(songID("So What")), "0")).To(Equal(http.StatusNotFound)) - }) - - It("hides another user's private playlist", func() { - Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusNotFound)) - Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) - }) - - It("forbids a non-owner on a public playlist", func() { - Expect(post("/Playlists/"+enc(plID), `{"Name":"Move","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) - Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusForbidden)) - Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) - }) - - It("rejects a negative index", func() { - Expect(move(adminUser, plID, entries[0].PlaylistItemId, "-1")).To(Equal(http.StatusBadRequest)) - }) - }) - - Describe("users", func() { - It("reports the current user as an editor", func() { - plID := createPlaylist("Perms", nil) - var perms []dto.PlaylistUserPermissions - parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms) - Expect(perms).To(HaveLen(1)) - Expect(perms[0].UserId).To(Equal(enc(testID("admin-1")))) - Expect(perms[0].CanEdit).To(BeTrue()) - }) - }) - - Describe("listing", func() { - It("lists a created playlist advertising a Primary image tag", func() { - createPlaylist("Listed", nil) - q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("Listed")) - Expect(q.Items[0].ImageTags).To(HaveKey("Primary")) - }) - - It("sorts playlists by name when SortBy=SortName", func() { - createPlaylist("Charlie", nil) - createPlaylist("Alpha", nil) - createPlaylist("Bravo", nil) - q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&SortBy=SortName")) - Expect(names(q.Items)).To(Equal([]string{"Alpha", "Bravo", "Charlie"})) - }) - }) - - // Jellify resolves the "playlists library" via a ManualPlaylistsFolder query, then lists - // playlists with ParentId set to that folder's id (no IncludeItemTypes). Without a folder item - // whose CollectionType is "playlists", its query resolves undefined and React Query retries in a - // backoff loop that stalls the home screen. - Describe("playlists library folder (ManualPlaylistsFolder)", func() { - It("returns a synthetic playlists folder with CollectionType=playlists", func() { - q := queryResult(get("/Items?includeItemTypes=ManualPlaylistsFolder&excludeItemTypes=CollectionFolder")) - Expect(q.Items).To(HaveLen(1)) - Expect(q.Items[0].CollectionType).To(Equal("playlists")) - Expect(q.Items[0].Id).To(Equal(dto.PlaylistsFolderGUID)) - }) - - It("lists the user's playlists when browsing the folder by ParentId (no IncludeItemTypes)", func() { - createPlaylist("My Mix", nil) - q := queryResult(get("/Items?parentId=" + dto.PlaylistsFolderGUID)) - Expect(names(q.Items)).To(ContainElement("My Mix")) - Expect(q.Items[0].Type).To(Equal("Playlist")) - // Jellify keeps only playlists whose Path contains "data". - Expect(q.Items[0].Path).To(ContainSubstring("data")) - }) - - It("resolves the synthetic playlists folder by its own advertised id", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+dto.PlaylistsFolderGUID), &item) - Expect(item.Type).To(Equal("ManualPlaylistsFolder")) - Expect(item.CollectionType).To(Equal("playlists")) - Expect(item.Id).To(Equal(dto.PlaylistsFolderGUID)) - }) - }) - - // Real Jellyfin returns a playlist's children for /Items?ParentId= with no - // IncludeItemTypes; generic clients (not Finamp/Jellify) browse playlists this way. - Describe("browsing a playlist via the generic /Items path", func() { - It("lists the playlist's tracks for a typeless ParentId query", func() { - plID := createPlaylist("Browse Me", []string{enc(songID("Come Together")), enc(songID("So What"))}) - q := queryResult(get("/Items?parentId=" + enc(plID))) - Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) - Expect(q.Items[0].Type).To(Equal("Audio")) - }) - - It("pages the playlist's tracks", func() { - plID := createPlaylist("Browse Paged", []string{enc(songID("Come Together")), enc(songID("So What"))}) - q := queryResult(get("/Items?parentId=" + enc(plID) + "&startIndex=1&limit=1")) - Expect(q.Items).To(HaveLen(1)) - Expect(q.TotalRecordCount).To(Equal(2)) - }) - - // Jellify opens a playlist with ParentId=&IncludeItemTypes=Audio&Recursive=false. - // The playlist id must resolve to its tracks, not be treated as an album id (which returns none). - It("lists the playlist's tracks even when IncludeItemTypes=Audio is set", func() { - plID := createPlaylist("Typed Browse", []string{enc(songID("Come Together")), enc(songID("So What"))}) - q := queryResult(get("/Items?parentId=" + enc(plID) + "&includeItemTypes=Audio&recursive=false")) - Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) - }) - }) - - Describe("cover art", func() { - // A real (decodable) image: the upload endpoint validates by decoding, like the native one. - var jpeg []byte - BeforeEach(func() { - var buf bytes.Buffer - Expect(jpeglib.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) - jpeg = buf.Bytes() - }) - - It("uploads and removes a playlist cover", func() { - plID := createPlaylist("Cover", nil) - - Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). - To(Equal(http.StatusNoContent)) - - pls, err := ds.Playlist().Get(ctx, plID) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.UploadedImage).ToNot(BeEmpty()) - _, statErr := os.Stat(pls.UploadedImagePath()) - Expect(statErr).ToNot(HaveOccurred(), "cover file should exist on disk") - - Expect(del("/Items/" + enc(plID) + "/Images/Primary").Code).To(Equal(http.StatusNoContent)) - pls, _ = ds.Playlist().Get(ctx, plID) - Expect(pls.UploadedImage).To(BeEmpty()) - }) - - It("rejects cover upload for a non-playlist item", func() { - Expect(upload(adminUser, "/Items/"+enc(albumID("IV"))+"/Images/Primary", "image/jpeg", jpeg).Code). - To(Equal(http.StatusNotImplemented)) - }) - - // An upload must clear the resolved artwork state, or clients keep serving the stale cover - // from their tag-keyed cache until the next scan. - It("clears the resolved image tag after a cover upload", func() { - plID := createPlaylist("Cover Tag", nil) - Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: plID, Hash: "1111111111111111", - })).To(Succeed()) - - imageTag := func() string { - q := queryResult(get("/Items?ids=" + enc(plID))) - Expect(q.Items).To(HaveLen(1)) - return q.Items[0].ImageTags["Primary"] - } - Expect(imageTag()).To(Equal("1111111111111111")) - - Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). - To(Equal(http.StatusNoContent)) - - // The upload re-queues resolution instead of resolving inline, so the tag goes bare. - Expect(imageTag()).ToNot(Equal("1111111111111111")) - }) - }) - - Describe("update", func() { - It("404s when one of the replacement Ids is malformed, leaving the track list unchanged", func() { - plID := createPlaylist("UpdateMalformed", []string{enc(songID("Come Together"))}) - body := `{"Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}` - Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNotFound)) - q := playlistItems(plID) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(names(q.Items)).To(ConsistOf("Come Together")) - }) - - It("makes a playlist public", func() { - plID := createPlaylist("Make Public", nil) - Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) - Expect(openAccess(plID)).To(BeTrue()) - // Now visible to other users. - Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) - }) - - It("renames a playlist", func() { - plID := createPlaylist("Old Name", nil) - Expect(post("/Playlists/"+enc(plID), `{"Name":"New Name"}`).Code).To(Equal(http.StatusNoContent)) - pls, _ := ds.Playlist().Get(ctx, plID) - Expect(pls.Name).To(Equal("New Name")) - }) - - It("replaces the track list when Ids are provided", func() { - plID := createPlaylist("Reorder", []string{enc(songID("Come Together")), enc(songID("Something"))}) - // Replace with a single different track. - Expect(post("/Playlists/"+enc(plID), `{"Ids":["`+enc(songID("So What"))+`"]}`).Code).To(Equal(http.StatusNoContent)) - q := playlistItems(plID) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("So What")) - }) - - It("clears the track list when an explicit empty Ids array is sent", func() { - plID := createPlaylist("Clear Me", []string{enc(songID("Come Together")), enc(songID("Something"))}) - Expect(post("/Playlists/"+enc(plID), `{"Ids":[]}`).Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(0)) - }) - - It("leaves the track list intact when Ids is omitted (metadata-only update)", func() { - plID := createPlaylist("Keep Tracks", []string{enc(songID("Come Together")), enc(songID("Something"))}) - Expect(post("/Playlists/"+enc(plID), `{"Name":"Renamed"}`).Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) - }) - - It("applies Name and IsPublic sent together with a track replacement", func() { - plID := createPlaylist("Combo", []string{enc(songID("Come Together"))}) - body := `{"Name":"Combo Renamed","IsPublic":true,"Ids":["` + enc(songID("So What")) + `"]}` - Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNoContent)) - q := playlistItems(plID) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("So What")) - pls, _ := ds.Playlist().Get(ctx, plID) - Expect(pls.Name).To(Equal("Combo Renamed")) - Expect(pls.Public).To(BeTrue()) - }) - - It("forbids a non-owner from updating a public playlist", func() { - plID := createPlaylist("Owned", nil) - post("/Playlists/"+enc(plID), `{"IsPublic":true}`) // make it visible to the regular user - Expect(postAs(regularUser, "/Playlists/"+enc(plID), `{"Name":"Hijacked"}`).Code).To(Equal(http.StatusForbidden)) - }) - - // An id that decodes to "" would tell Create to make a new playlist instead of updating one — - // itemIDParam must 404 before that decode ever runs, not silently create one. - It("404s for a malformed playlist id, without creating a playlist", func() { - before, err := ds.Playlist().CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - - w := post("/Playlists/00000000000000000000000000000000", `{"Ids":["`+enc(songID("So What"))+`"]}`) - Expect(w.Code).To(Equal(http.StatusNotFound)) - - after, err := ds.Playlist().CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(after).To(Equal(before)) - }) - }) - - Describe("delete", func() { - It("deletes a playlist", func() { - plID := createPlaylist("ToDelete", nil) - Expect(del("/Items/" + enc(plID)).Code).To(Equal(http.StatusNoContent)) - Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) - }) - - It("returns 404 when deleting a non-playlist item", func() { - Expect(del("/Items/" + enc(albumID("IV"))).Code).To(Equal(http.StatusNotFound)) - }) - }) -}) diff --git a/server/jellyfin/e2e/quickconnect_test.go b/server/jellyfin/e2e/quickconnect_test.go deleted file mode 100644 index 1beffc56f..000000000 --- a/server/jellyfin/e2e/quickconnect_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package e2e - -import ( - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("QuickConnect", func() { - BeforeEach(func() { - setupTestDB() - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.QuickConnect = true - }) - - clientReq := func(deviceID, method, path, body string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest(method, path, strings.NewReader(body)) - r.Header.Set("Authorization", `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="`+deviceID+`", Version="1.0"`) - r.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, r) - return w - } - initiate := func() dto.QuickConnectResult { - var pending dto.QuickConnectResult - parseInto(clientReq("new-device", "POST", "/QuickConnect/Initiate", ""), &pending) - Expect(pending.Authenticated).To(BeFalse()) - return pending - } - redeem := func(deviceID, secret string) *httptest.ResponseRecorder { - return clientReq(deviceID, "POST", "/Users/AuthenticateWithQuickConnect", `{"Secret":"`+secret+`"}`) - } - - It("signs a new client in with a code approved by a signed-in user", func() { - Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("true")) - pending := initiate() - - Expect(postAs(regularUser, "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusOK)) - - var status dto.QuickConnectResult - parseInto(rawReq("GET", "/QuickConnect/Connect?Secret="+pending.Secret, ""), &status) - Expect(status.Authenticated).To(BeTrue()) - - // Android TV redeems with a different DeviceId than it initiated with. - w := redeem("other-device", pending.Secret) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.AuthenticationResult - parseInto(w, &res) - Expect(res.User.Name).To(Equal(regularUser.UserName)) - Expect(res.SessionInfo).ToNot(BeNil()) - Expect(res.SessionInfo.DeviceId).To(Equal("other-device")) - - r := httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Token", res.AccessToken) - me := httptest.NewRecorder() - router.ServeHTTP(me, r) - Expect(me.Code).To(Equal(http.StatusOK)) - - Expect(redeem("new-device", pending.Secret).Code).To(Equal(http.StatusNotFound)) - }) - - It("lets an admin approve a code for another user", func() { - pending := initiate() - Expect(post("/QuickConnect/Authorize?Code="+pending.Code+"&UserId="+enc(regularUser.ID), "").Code). - To(Equal(http.StatusOK)) - - var res dto.AuthenticationResult - parseInto(redeem("new-device", pending.Secret), &res) - Expect(res.User.Name).To(Equal(regularUser.UserName)) - }) - - It("requires authentication to approve a code", func() { - pending := initiate() - Expect(rawReq("POST", "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusUnauthorized)) - }) - - It("answers 401 on every Quick Connect call when disabled", func() { - conf.Server.Jellyfin.QuickConnect = false - Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("false")) - Expect(clientReq("new-device", "POST", "/QuickConnect/Initiate", "").Code).To(Equal(http.StatusUnauthorized)) - Expect(rawReq("GET", "/QuickConnect/Connect?Secret=x", "").Code).To(Equal(http.StatusUnauthorized)) - Expect(post("/QuickConnect/Authorize?Code=123456", "").Code).To(Equal(http.StatusUnauthorized)) - Expect(redeem("new-device", "x").Code).To(Equal(http.StatusUnauthorized)) - }) -}) diff --git a/server/jellyfin/e2e/routing_test.go b/server/jellyfin/e2e/routing_test.go deleted file mode 100644 index 49faad342..000000000 --- a/server/jellyfin/e2e/routing_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package e2e - -import ( - "net/http" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Routing", func() { - BeforeEach(func() { setupTestDB() }) - - It("routes authenticated endpoints case-insensitively", func() { - // Lowercase path variant of GET /Items — real clients (jellyfin-apiclient-python) send these. - lower := queryResult(get("/items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(lower.TotalRecordCount).To(Equal(5)) - }) - - It("returns a JSON 404 for an unknown route", func() { - w := get("/Nonexistent/Route") - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(w.Header().Get("Content-Type")).To(HavePrefix("application/json")) - Expect(w.Body.String()).To(ContainSubstring("{}")) - }) - - It("returns 404 for an unsupported method on a known path", func() { - // PUT isn't registered for /Items; the MethodNotAllowed handler maps to the same JSON 404. - w := jReq(adminUser, "PUT", "/Items", "") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) -}) diff --git a/server/jellyfin/e2e/search_test.go b/server/jellyfin/e2e/search_test.go deleted file mode 100644 index fa14f6811..000000000 --- a/server/jellyfin/e2e/search_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package e2e - -import ( - "fmt" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Search with a ParentId library scope is how Finamp drives its search screen. Artists are the -// tricky case: they have no library_id column, so the repo's Search does its own scope handling. -var _ = Describe("Search", func() { - BeforeEach(func() { setupTestDB() }) - - lib1 := func() string { return dto.EncodeLibraryID(1) } - - Describe("artists", func() { - It("searches all album artists", func() { - q := queryResult(get("/Artists/AlbumArtists?SearchTerm=Beatles")) - Expect(names(q.Items)).To(ConsistOf("The Beatles")) - }) - - It("searches album artists scoped to a library (ParentId)", func() { - q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1() + "&SearchTerm=Beatles&Recursive=true&SortBy=SortName")) - Expect(names(q.Items)).To(ConsistOf("The Beatles")) - }) - - It("returns an empty result for a non-matching term", func() { - q := queryResult(get("/Artists?ParentId=" + lib1() + "&SearchTerm=nonexistentxyz")) - Expect(q.Items).To(BeEmpty()) - }) - }) - - Describe("albums and songs", func() { - It("searches albums scoped to a library", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Abbey")) - Expect(names(q.Items)).To(ContainElement("Abbey Road")) - }) - - It("searches songs scoped to a library", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Stairway")) - Expect(names(q.Items)).To(ContainElement("Stairway To Heaven")) - }) - }) - - Describe("pagination totals", func() { - It("reports the search match count, not the unfiltered library count", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey&Limit=50")) - Expect(q.Items).To(HaveLen(1)) - Expect(q.TotalRecordCount).To(Equal(1)) // not the 5-album library total - }) - - It("reaches the true total when paging song search results", func() { - // "So" prefix-matches several songs (titles and Solo Artist's tracks); learn the true - // count from an unpaged query, then walk one-item pages: the reported total must keep - // the client paging until the last match and stop it exactly there. - all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So")) - total := all.TotalRecordCount - Expect(total).To(Equal(len(all.Items))) - Expect(total).To(BeNumerically(">=", 2)) - - var collected []string - for start := range total { - page := queryResult(get(fmt.Sprintf("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So&Limit=1&StartIndex=%d", start))) - Expect(page.Items).To(HaveLen(1)) - if start+1 < total { - Expect(page.TotalRecordCount).To(BeNumerically(">", start+1)) // more remain: keep paging - } else { - Expect(page.TotalRecordCount).To(Equal(total)) // last page: exact, so the client stops - } - collected = append(collected, page.Items[0].Name) - } - Expect(collected).To(ConsistOf(names(all.Items))) - }) - }) -}) diff --git a/server/jellyfin/e2e/sessions_test.go b/server/jellyfin/e2e/sessions_test.go deleted file mode 100644 index 22c87b108..000000000 --- a/server/jellyfin/e2e/sessions_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package e2e - -import ( - "net/http" - "strconv" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Sessions", func() { - BeforeEach(func() { setupTestDB() }) - - ticks := func(ms int64) int64 { return ms * 10_000 } - reportBody := func(itemID string, positionTicks int64) string { - return `{"ItemId":"` + enc(itemID) + `","PositionTicks":` + strconv.FormatInt(positionTicks, 10) + `}` - } - - Describe("playback reporting", func() { - It("accepts a playback start report", func() { - Expect(post("/Sessions/Playing", reportBody(songID("Come Together"), 0)).Code).To(Equal(http.StatusNoContent)) - }) - - It("accepts a playback progress report", func() { - Expect(post("/Sessions/Playing/Progress", reportBody(songID("Come Together"), ticks(5000))).Code).To(Equal(http.StatusNoContent)) - }) - - It("counts a play stopped past the threshold", func() { - id := songID("So What") - mf, err := ds.MediaFile().Get(ctx, id) - Expect(err).ToNot(HaveOccurred()) - // Report a stop at the end of the track — comfortably past 50% / the 4-minute cap. - Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(int64(mf.Duration*1000)))).Code).To(Equal(http.StatusNoContent)) - - mf, err = ds.MediaFile().Get(ctx, id) - Expect(err).ToNot(HaveOccurred()) - Expect(mf.PlayCount).To(BeNumerically(">=", 1)) - }) - - It("acknowledges a playback session ping", func() { - Expect(post("/Sessions/Playing/Ping?playSessionId=abc", "").Code).To(Equal(http.StatusNoContent)) - }) - - It("does not count a brief play stopped before the threshold", func() { - // Regression: Finamp sends a Stopped report on every track switch, so an immediate skip - // (1 second in) must not mark the track played. Seeded tracks are >= 120s, so the 50% - // threshold is always well above 1s. - id := songID("Help!") - Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(1000))).Code).To(Equal(http.StatusNoContent)) - - mf, err := ds.MediaFile().Get(ctx, id) - Expect(err).ToNot(HaveOccurred()) - Expect(mf.PlayCount).To(Equal(int64(0))) - }) - }) - - Describe("capabilities", func() { - It("acknowledges POST /Sessions/Capabilities", func() { - Expect(post("/Sessions/Capabilities", "{}").Code).To(Equal(http.StatusNoContent)) - }) - - It("acknowledges POST /Sessions/Capabilities/Full", func() { - Expect(post("/Sessions/Capabilities/Full", "{}").Code).To(Equal(http.StatusNoContent)) - }) - }) -}) diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go deleted file mode 100644 index a5d2505ba..000000000 --- a/server/jellyfin/e2e/similar_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Similar", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("GET /Artists/{id}/Similar", func() { - It("returns the provider's similar artists, excluding ones not in the library", func() { - providerFake.similarArtists = model.Artists{ - {ID: testID("z"), Name: "Led Zeppelin"}, - {ID: "", Name: "Not In Library"}, // no id -> not present -> excluded - } - q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) - Expect(names(q.Items)).To(ConsistOf("Led Zeppelin")) - Expect(q.Items[0].Type).To(Equal("MusicArtist")) - }) - - It("returns an empty result (not 404) when the provider has nothing", func() { - q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) - Expect(q.Items).To(BeEmpty()) - Expect(q.TotalRecordCount).To(Equal(0)) - }) - - // Unlike an unresolvable-but-well-formed id (empty result above), a malformed itemId never - // reaches provider lookup at all — itemIDParam rejects it first. - It("404s a malformed itemId", func() { - w := get("/Artists/not-a-valid-id/Similar") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("GET /Items/{id}/Similar", func() { - It("returns similar songs for a track", func() { - providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Similar Song", LibraryID: 1}} - q := queryResult(get("/Items/" + enc(songID("So What")) + "/Similar")) - Expect(names(q.Items)).To(ConsistOf("Similar Song")) - Expect(q.Items[0].Type).To(Equal("Audio")) - }) - - It("excludes similar songs from libraries the user can't access", func() { - providerFake.similarSongs = model.MediaFiles{ - {ID: testID("x1"), Title: "In Library", LibraryID: 1}, - {ID: testID("x2"), Title: "Other Library", LibraryID: 2}, // regularUser has no access - } - q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/Similar")) - Expect(names(q.Items)).To(ConsistOf("In Library")) - }) - - It("returns similar albums (derived from similar songs, de-duplicated) for an album", func() { - providerFake.similarSongs = model.MediaFiles{ - {ID: testID("x1"), AlbumID: albumID("IV")}, - {ID: testID("x2"), AlbumID: albumID("IV")}, // same album -> counted once - {ID: testID("x3"), AlbumID: albumID("Kind of Blue")}, - } - q := queryResult(get("/Items/" + enc(albumID("Abbey Road")) + "/Similar")) - Expect(names(q.Items)).To(Equal([]string{"IV", "Kind of Blue"})) - Expect(q.Items[0].Type).To(Equal("MusicAlbum")) - }) - - It("excludes similar albums from libraries the user can't access", func() { - // Seed an album in a second library the regular user has no access to, and point a - // provider similar-song at it. - otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} - Expect(ds.Library().Put(ctx, &otherLib)).To(Succeed()) - otherAlbum := model.Album{ID: testID("other-album"), Name: "Other Album", LibraryID: 2} - Expect(ds.Album().Put(ctx, &otherAlbum)).To(Succeed()) - - providerFake.similarSongs = model.MediaFiles{ - {ID: testID("x1"), AlbumID: albumID("IV")}, // library 1 -> visible - {ID: testID("x2"), AlbumID: testID("other-album")}, // library 2 -> filtered for regularUser - } - q := queryResult(getAs(regularUser, "/Items/"+enc(albumID("Abbey Road"))+"/Similar")) - Expect(names(q.Items)).To(ConsistOf("IV")) - }) - - It("returns an empty result (not 404) for an unknown item, so the client stops retrying", func() { - q := queryResult(get("/Items/" + enc(testID("does-not-exist")) + "/Similar")) - Expect(q.Items).To(BeEmpty()) - }) - - It("404s a malformed itemId", func() { - w := get("/Items/not-a-valid-id/Similar") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - // Finamp plays exactly what InstantMix returns, so a track seed must lead its own mix. - Describe("GET /Items/{id}/InstantMix", func() { - It("returns the seed track first, followed by similar songs", func() { - providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Similar Song", LibraryID: 1}} - q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=19")) - Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) - Expect(q.Items[0].Type).To(Equal("Audio")) - }) - - It("does not duplicate the seed when the provider returns it", func() { - providerFake.similarSongs = model.MediaFiles{ - {ID: songID("So What"), Title: "So What", LibraryID: 1}, - {ID: testID("x1"), Title: "Similar Song", LibraryID: 1}, - } - q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix")) - Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) - }) - - It("caps the mix at the requested limit", func() { - providerFake.similarSongs = model.MediaFiles{ - {ID: testID("x1"), Title: "S1", LibraryID: 1}, - {ID: testID("x2"), Title: "S2", LibraryID: 1}, - {ID: testID("x3"), Title: "S3", LibraryID: 1}, - } - q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=2")) - Expect(names(q.Items)).To(Equal([]string{"So What", "S1"})) - }) - - It("excludes similar songs from libraries the user can't access", func() { - providerFake.similarSongs = model.MediaFiles{ - {ID: testID("x1"), Title: "In Library", LibraryID: 1}, - {ID: testID("x2"), Title: "Other Library", LibraryID: 2}, - } - q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/InstantMix")) - Expect(names(q.Items)).To(Equal([]string{"So What", "In Library"})) - }) - - It("returns a mix of the provider's similar songs for an artist seed", func() { - providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Artist Mix Song", LibraryID: 1}} - q := queryResult(get("/Items/" + enc(artistID("Miles Davis")) + "/InstantMix")) - Expect(names(q.Items)).To(Equal([]string{"Artist Mix Song"})) - }) - - It("returns an empty result (not 404) for an unknown item", func() { - w := get("/Items/" + enc(testID("does-not-exist")) + "/InstantMix") - Expect(w.Code).To(Equal(200)) - Expect(queryResult(w).Items).To(BeEmpty()) - }) - - It("returns only the seed when the provider has nothing", func() { - q := queryResult(get("/Items/" + enc(songID("Help!")) + "/InstantMix")) - Expect(names(q.Items)).To(Equal([]string{"Help!"})) - }) - - It("404s a malformed itemId", func() { - w := get("/Items/not-a-valid-id/InstantMix") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("type-specific InstantMix routes", func() { - BeforeEach(func() { - providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Mix Song", LibraryID: 1}} - }) - - It("leads a song mix with the seed on /Songs/{id}/InstantMix", func() { - q := queryResult(get("/Songs/" + enc(songID("So What")) + "/InstantMix")) - Expect(names(q.Items)).To(Equal([]string{"So What", "Mix Song"})) - }) - - DescribeTable("returns the provider's mix", - func(path func() string) { - q := queryResult(get(path())) - Expect(names(q.Items)).To(Equal([]string{"Mix Song"})) - }, - Entry("Albums/{id}", func() string { return "/Albums/" + enc(albumID("Abbey Road")) + "/InstantMix" }), - Entry("Artists/{id}", func() string { return "/Artists/" + enc(artistID("Miles Davis")) + "/InstantMix" }), - Entry("Playlists/{id}", func() string { - return "/Playlists/" + enc(createPlaylist("Seed", []string{enc(songID("So What"))})) + "/InstantMix" - }), - Entry("Artists/InstantMix?id=", func() string { return "/Artists/InstantMix?id=" + enc(artistID("Miles Davis")) }), - Entry("MusicGenres/InstantMix?id=", func() string { return "/MusicGenres/InstantMix?id=" + enc(genreID("Jazz")) }), - ) - - It("404s a malformed id query param", func() { - Expect(get("/Artists/InstantMix?id=not-a-valid-id").Code).To(Equal(http.StatusNotFound)) - }) - }) -}) diff --git a/server/jellyfin/e2e/smoke_test.go b/server/jellyfin/e2e/smoke_test.go deleted file mode 100644 index b26955632..000000000 --- a/server/jellyfin/e2e/smoke_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Smoke test: proves the harness boots (DB, scan, snapshot, router, token auth) and the seeded -// library is queryable end-to-end. Broader per-endpoint coverage lives in the sibling files. -var _ = Describe("Smoke", func() { - BeforeEach(func() { setupTestDB() }) - - It("serves public system info without auth", func() { - w := rawReq("GET", "/System/Info/Public", "") - Expect(w.Code).To(Equal(http.StatusOK)) - var info map[string]any - parseInto(w, &info) - Expect(info).To(HaveKey("ServerName")) - Expect(info).To(HaveKey("Version")) - }) - - It("rejects an authenticated endpoint without a token", func() { - w := rawReq("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", "") - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("lists the seeded albums for an authenticated user", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(5)) - names := make([]string, 0, len(q.Items)) - for _, it := range q.Items { - Expect(it.Type).To(Equal("MusicAlbum")) - names = append(names, it.Name) - } - Expect(names).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) - }) - - It("resolves a seeded album id round-trip (encoded in the URL)", func() { - id := albumID("Abbey Road") - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(id)), &item) - Expect(item.Id).To(Equal(enc(id))) - Expect(item.Name).To(Equal("Abbey Road")) - Expect(item.Type).To(Equal("MusicAlbum")) - }) -}) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go deleted file mode 100644 index 9b7380185..000000000 --- a/server/jellyfin/e2e/streaming_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package e2e - -import ( - "fmt" - "net/http" - "strings" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Streaming", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("GET /Audio/{id}/stream", func() { - It("streams the requested track", func() { - id := songID("Come Together") - w := get("/Audio/" + enc(id) + "/stream") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(Equal("fake audio data")) - Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) - }) - - It("streams via the /universal endpoint", func() { - id := songID("So What") - Expect(get("/Audio/" + enc(id) + "/universal").Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) - }) - - It("serves the stream.{container} path form", func() { - id := songID("Help!") - Expect(get("/Audio/" + enc(id) + "/stream.mp3").Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) - }) - - It("forces raw format when static=true", func() { - // With ffmpeg unavailable the decider direct-plays regardless, but static=true must - // never resolve to a transcode. - id := songID("Help!") - get("/Audio/" + enc(id) + "/stream?static=true") - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) - }) - - It("returns 404 for an unknown track", func() { - Expect(get("/Audio/" + enc(testID("nope")) + "/stream").Code).To(Equal(http.StatusNotFound)) - }) - - DescribeTable("answers HEAD (Fintunes probes the type before playing or downloading)", - func(path string) { - w := jReq(adminUser, "HEAD", fmt.Sprintf(path, enc(songID("Help!"))), "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).ToNot(BeEmpty()) - }, - Entry("stream", "/Audio/%s/stream"), - Entry("stream.{container}", "/Audio/%s/stream.mp3"), - Entry("universal", "/Audio/%s/universal"), - Entry("File", "/Items/%s/File"), - Entry("Download", "/Items/%s/Download"), - ) - }) - - Describe("GET /Audio/{id}/main.m3u8 (Finamp transcoding mode)", func() { - It("returns a VOD playlist whose segment streams through the transcode pipeline", func() { - id := songID("Come Together") - w := get("/Audio/" + enc(id) + "/main.m3u8?audioCodec=aac&audioBitRate=320000") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) - body := w.Body.String() - Expect(body).To(HavePrefix("#EXTM3U\n")) - Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) - - // Fetch the advertised segment like an HLS player would. - var segment string - for _, line := range strings.Split(body, "\n") { - if line != "" && !strings.HasPrefix(line, "#") { - segment = line - } - } - Expect(segment).To(HavePrefix("stream.aac?")) - Expect(get("/Audio/" + enc(id) + "/" + segment).Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) - Expect(streamerSpy.LastRequest.Format).To(Equal("aac")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(320)) - }) - - It("is reachable with Jellyfin's case-insensitive routing", func() { - id := songID("Come Together") - Expect(get("/audio/" + enc(id) + "/Main.m3u8").Code).To(Equal(http.StatusOK)) - }) - }) - - Describe("direct-file endpoints", func() { - It("serves /Items/{id}/File as direct play (raw)", func() { - id := songID("Something") - w := get("/Items/" + enc(id) + "/File") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) - }) - - It("serves /Items/{id}/Download", func() { - id := songID("Something") - Expect(get("/Items/" + enc(id) + "/Download").Code).To(Equal(http.StatusOK)) - }) - }) - - Describe("PlaybackInfo", func() { - It("returns a single direct-play MediaSource via GET", func() { - id := songID("So What") - var info dto.PlaybackInfoResponse - parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) - Expect(info.MediaSources).To(HaveLen(1)) - Expect(info.MediaSources[0].Id).ToNot(BeEmpty()) - Expect(info.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$")) - }) - - It("returns a MediaSource via POST", func() { - id := songID("So What") - var info dto.PlaybackInfoResponse - parseInto(post("/Items/"+enc(id)+"/PlaybackInfo", "{}"), &info) - Expect(info.MediaSources).To(HaveLen(1)) - }) - - It("embeds a self-authenticating TranscodingUrl (for native players that omit auth headers)", func() { - id := songID("So What") - var info dto.PlaybackInfoResponse - parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) - streamURL := info.MediaSources[0].TranscodingUrl - // Server-relative: clients append it to a base URL already carrying /jellyfin. - Expect(streamURL).To(HavePrefix("/Audio/" + enc(id) + "/universal")) - Expect(streamURL).ToNot(HavePrefix(consts.URLPathJellyfinAPI)) - Expect(streamURL).To(ContainSubstring("api_key=")) - // The embedded api_key alone must authenticate the stream — no auth header sent. - w := rawReq("GET", streamURL, "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) - }) - }) -}) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go deleted file mode 100644 index 51443bdc9..000000000 --- a/server/jellyfin/e2e/system_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package e2e - -import ( - "net/http" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("System", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("GET /System/Info/Public", func() { - It("returns public server info without authentication", func() { - w := rawReq("GET", "/System/Info/Public", "") - Expect(w.Code).To(Equal(http.StatusOK)) - var info map[string]any - parseInto(w, &info) - Expect(info["ServerName"]).To(HavePrefix("Navidrome")) - Expect(info["ProductName"]).To(Equal("Jellyfin Server")) - Expect(info["StartupWizardCompleted"]).To(BeTrue()) - Expect(info["Id"]).ToNot(BeEmpty()) - Expect(info["Version"]).ToNot(BeEmpty()) - }) - - It("routes case-insensitively (lowercase path)", func() { - w := rawReq("GET", "/system/info/public", "") - Expect(w.Code).To(Equal(http.StatusOK)) - }) - }) - - Describe("GET /System/Info", func() { - It("returns system info to any authenticated user, matching the public Version", func() { - w := getAs(regularUser, "/System/Info") - var info map[string]any - parseInto(w, &info) - Expect(info["Version"]).ToNot(BeEmpty()) - Expect(info["SupportsLibraryMonitor"]).To(BeTrue()) - - pub := rawReq("GET", "/System/Info/Public", "") - var pubInfo map[string]any - parseInto(pub, &pubInfo) - Expect(info["Version"]).To(Equal(pubInfo["Version"])) - Expect(info["Id"]).To(Equal(pubInfo["Id"])) - }) - - It("rejects unauthenticated requests", func() { - w := rawReq("GET", "/System/Info", "") - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET /System/Endpoint", func() { - It("always reports IsInNetwork, which Finamp's connection test probes for", func() { - w := getAs(regularUser, "/System/Endpoint") - Expect(w.Code).To(Equal(http.StatusOK)) - var info map[string]any - parseInto(w, &info) - Expect(info).To(HaveKey("IsInNetwork")) - Expect(info).To(HaveKey("IsLocal")) - }) - - It("rejects unauthenticated requests", func() { - w := rawReq("GET", "/System/Endpoint", "") - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET/POST /System/Ping", func() { - It("answers GET with a plain-text server name", func() { - w := rawReq("GET", "/System/Ping", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(HavePrefix("text/plain")) - Expect(w.Body.String()).To(HavePrefix("Navidrome")) - }) - - It("answers POST identically", func() { - w := rawReq("POST", "/System/Ping", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(strings.TrimSpace(w.Body.String())).To(HavePrefix("Navidrome")) - }) - }) -}) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go deleted file mode 100644 index 6af533dbb..000000000 --- a/server/jellyfin/images.go +++ /dev/null @@ -1,203 +0,0 @@ -package jellyfin - -import ( - "bytes" - "context" - "encoding/base64" - "errors" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "io" - "net/http" - - "github.com/dustin/go-humanize" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/imghttp" - "github.com/navidrome/navidrome/utils/req" - _ "golang.org/x/image/webp" -) - -// imageSize reduces Jellyfin's size params to Navidrome's single bound. Width/Height/Max* are -// bounds, so the tightest wins; Fill* must cover its box, so its larger side is the bound. -func imageSize(p *req.Values) int { - fill := max(p.IntOr("fillwidth", 0), p.IntOr("fillheight", 0)) - size := 0 - for _, v := range []int{p.IntOr("width", 0), p.IntOr("height", 0), p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0), fill} { - if v > 0 && (size == 0 || v < size) { - size = v - } - } - return size -} - -func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { - // Public, like Jellyfin's own image routes: clients build cover URLs without credentials, and - // upstream resolves them with no visibility check either (LibraryManager.ItemIsVisible, null user). - ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) - itemId, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - size := imageSize(req.Params(r)) - - artID := api.resolveArtworkID(ctx, itemId) - img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) - switch { - case errors.Is(err, context.Canceled): - return - case err != nil: - log.Warn(ctx, "Error retrieving artwork", "id", itemId, err) - http.Error(w, "Not Found", http.StatusNotFound) - return - } - defer img.Close() - if imghttp.WriteImageHeaders(w, r, img, hashFromTag(r)) { - return - } - // Leave Content-Type unset so net/http sniffs it (covers may be PNG/WebP/JPEG). - _, _ = io.Copy(w, img) -} - -// hashFromTag returns the ?tag query param when it is exactly a 16-char lowercase-hex content -// hash (what Finamp/Jellyfin clients append), so a matching request can be served immutable. -func hashFromTag(r *http.Request) string { - tag := r.URL.Query().Get("tag") - if len(tag) != 16 { - return "" - } - for _, c := range tag { - if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { - return "" - } - } - return tag -} - -// resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing -// album -> artist -> media file -> playlist. -func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string { - if al, err := api.ds.Album().Get(ctx, itemId); err == nil { - return al.CoverArtID().String() - } - if ar, err := api.ds.Artist().Get(ctx, itemId); err == nil { - return ar.CoverArtID().String() - } - if mf, err := api.ds.MediaFile().Get(ctx, itemId); err == nil { - return mf.CoverArtID().String() - } - if pl, err := api.ds.Playlist().Get(ctx, itemId); err == nil { - return pl.CoverArtID().String() - } - return (model.ArtworkID{}).String() -} - -// postItemImage handles cover upload. Only playlists are writable here; album/artist covers come -// from scanning. Past the auth and id gates the body is drained before answering — including on the -// not-implemented path — because Finamp writes it synchronously and would see a broken pipe. -func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - - // Honor the same artwork-upload gate and size cap as the native endpoint. - u, _ := request.UserFrom(ctx) - if !conf.Server.EnableArtworkUpload && !u.IsAdmin { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - // The limit caps the decoded image (native endpoint semantics); Jellyfin clients base64-encode - // the wire body (4/3 bigger), so the read cap allows for inflation. - limit := artwork.MaxImageUploadSize() - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit*4/3+4)) - if err != nil { - log.Warn(ctx, "Jellyfin API: cover upload rejected: body exceeds MaxImageUploadSize", - "playlistId", id, "limit", humanize.Bytes(uint64(limit)), err) - http.Error(w, "file too large", http.StatusBadRequest) - return - } - - if _, err := api.playlists.Get(ctx, id); err != nil { - http.Error(w, "Not Implemented", http.StatusNotImplemented) - return - } - - imgBytes, err := decodeImageBody(body) - if err != nil { - log.Warn(ctx, "Jellyfin API: cover upload rejected: body is neither an image nor base64", "playlistId", id, err) - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - if int64(len(imgBytes)) > limit { - log.Warn(ctx, "Jellyfin API: cover upload rejected: image exceeds MaxImageUploadSize", - "playlistId", id, "size", humanize.Bytes(uint64(len(imgBytes))), "limit", humanize.Bytes(uint64(limit))) - http.Error(w, "file too large", http.StatusBadRequest) - return - } - // Validate by decoding and derive the extension from the real format — clients lie in Content-Type. - _, format, err := image.DecodeConfig(bytes.NewReader(imgBytes)) - if err != nil { - log.Warn(ctx, "Jellyfin API: cover upload rejected: not a valid image", "playlistId", id, err) - http.Error(w, "invalid image file", http.StatusBadRequest) - return - } - ext := "." + format - - if err := api.playlists.SetImage(ctx, id, bytes.NewReader(imgBytes), ext); err != nil { - api.internalError(w, r, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// deleteItemImage removes a playlist's uploaded cover. Only playlists are supported. -func (api *Router) deleteItemImage(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - - if _, err := api.playlists.Get(ctx, id); err != nil { - http.Error(w, "Not Implemented", http.StatusNotImplemented) - return - } - - if err := api.playlists.RemoveImage(ctx, id); err != nil { - api.internalError(w, r, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// decodeImageBody returns the raw image bytes. Jellyfin base64-encodes the body, but some clients -// send raw bytes, so input already starting with an image magic number is passed through as-is. -func decodeImageBody(body []byte) ([]byte, error) { - if isImageMagic(body) { - return body, nil - } - trimmed := bytes.TrimSpace(body) - return base64.StdEncoding.DecodeString(string(trimmed)) -} - -func isImageMagic(b []byte) bool { - switch { - case len(b) >= 2 && b[0] == 0xFF && b[1] == 0xD8: // JPEG - return true - case bytes.HasPrefix(b, []byte{0x89, 'P', 'N', 'G'}): // PNG - return true - case bytes.HasPrefix(b, []byte("GIF8")): // GIF (GIF87a/GIF89a) - return true - case len(b) >= 12 && bytes.HasPrefix(b, []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP")): // WebP - return true - default: - return false - } -} diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go deleted file mode 100644 index b7cf9b7e7..000000000 --- a/server/jellyfin/images_test.go +++ /dev/null @@ -1,479 +0,0 @@ -package jellyfin - -import ( - "bytes" - "context" - "encoding/base64" - "errors" - "image" - "image/gif" - "image/jpeg" - "image/png" - "io" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type fakeArtwork struct { - artwork.Artwork - recvId string - recvSize int - recvCtx context.Context - data []byte - hash string -} - -func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { - f.recvId = id - f.recvSize = size - f.recvCtx = ctx - data := f.data - if data == nil { - data = []byte("IMG") - } - return &artwork.Image{ - ReadCloser: io.NopCloser(bytes.NewReader(data)), - Hash: f.hash, - LastUpdated: time.Now(), - }, nil -} - -func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+itemId+"/Images/Primary", nil) - rctx := chi.NewRouteContext() - rctx.URLParams.Add("itemId", itemId) - rctx.URLParams.Add("type", "Primary") - r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) - return w, r -} - -var _ = Describe("Images", func() { - // Real Jellyfin fits the image inside either bound, so a client that sends only MaxHeight must - // still get a resized image rather than the full-size original. - DescribeTable("derives the requested size from the Jellyfin size params", - func(query string, wantSize int) { - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - r.URL.RawQuery = query - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvSize).To(Equal(wantSize)) - }, - Entry("MaxWidth only", "maxwidth=300", 300), - Entry("MaxHeight only", "maxheight=300", 300), - Entry("both, smaller bound wins", "maxwidth=200&maxheight=300", 200), - Entry("both, smaller bound wins regardless of order", "maxwidth=300&maxheight=200", 200), - Entry("neither", "", 0), - Entry("Width only", "width=300", 300), - Entry("Height only", "height=300", 300), - Entry("Width capped by MaxWidth", "width=500&maxwidth=300", 300), - Entry("FillWidth only", "fillwidth=300", 300), - Entry("FillHeight only", "fillheight=300", 300), - Entry("both fill sides, larger one covers the box", "fillwidth=200&fillheight=300", 300), - Entry("fill smaller than MaxWidth wins", "maxwidth=500&fillwidth=300&fillheight=300", 300), - Entry("MaxWidth smaller than fill wins", "maxwidth=200&fillwidth=300&fillheight=300", 200), - Entry("non-positive values are ignored", "fillwidth=0&maxwidth=-5&height=250", 250), - ) - - It("streams album artwork", func() { - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(Equal("IMG")) - Expect(fa.recvId).To(ContainSubstring(testID("a1"))) - }) - - // A malformed itemId now 404s via itemIDParam instead of falling through to a placeholder image. - It("404s a malformed itemId instead of serving a placeholder", func() { - ds := &tests.MockDataStore{} - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest("not-a-valid-id") - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(fa.recvId).To(BeEmpty(), "artwork resolution must not run for an undecodable id") - }) - - // resolveArtworkID probes the entity tables, so a deleted item yields no artwork id at all. - It("asks for no artwork once the item is deleted, rather than its lingering state", func() { - ds := &tests.MockDataStore{} // no albums/artists/tracks/playlists at all - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("deleted-item"))) - api.getItemImage(w, r) - - // 200 here is what separates a well-formed unknown id from a malformed one, which 404s. - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).To(BeEmpty(), "an empty artwork id can only yield a placeholder") - }) - - It("sniffs the Content-Type instead of hardcoding it", func() { - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - - png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...) - fa := &fakeArtwork{data: png} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) - }) - - It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { - ds := &tests.MockDataStore{} - ds.Playlist().(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("pl1"), Name: "Mix", OwnerID: testID("someone")}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("pl1"))) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).To(ContainSubstring(testID("pl1"))) - }) - - // This endpoint is public (no user in the request), so artwork must be resolved under an - // elevated context; otherwise a private playlist's cover fails its visibility filter and - // silently falls back to the placeholder. - It("resolves artwork under an elevated admin context", func() { - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - u, ok := request.UserFrom(fa.recvCtx) - Expect(ok).To(BeTrue()) - Expect(u.IsAdmin).To(BeTrue()) - }) - - It("serves immutable when the tag param asserts the current hash", func() { - const hash = "0123456789abcdef" - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - fa := &fakeArtwork{hash: hash} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - q := r.URL.Query() - q.Set("tag", hash) - r.URL.RawQuery = q.Encode() - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) - Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) - }) - - It("revalidates via no-cache when no tag is provided", func() { - const hash = "0123456789abcdef" - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - fa := &fakeArtwork{hash: hash} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) - }) -}) - -// Real image fixtures: postItemImage validates uploads by decoding them. -func pngBytes() []byte { - var b bytes.Buffer - Expect(png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)))).To(Succeed()) - return b.Bytes() -} - -func jpegBytes() []byte { - var b bytes.Buffer - Expect(jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) - return b.Bytes() -} - -func gifBytes() []byte { - var b bytes.Buffer - Expect(gif.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) - return b.Bytes() -} - -// 1x1 WebP (Go's webp support is decode-only, so this one is pre-encoded). -func webpBytes() []byte { - b, err := base64.StdEncoding.DecodeString( - "UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA=") - Expect(err).ToNot(HaveOccurred()) - return b -} - -var _ = Describe("postItemImage", func() { - var api *Router - var fp *fakePlaylists - - BeforeEach(func() { - fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}} - api = &Router{playlists: fp} - }) - - It("uploads a raw JPEG body and returns 204", func() { - body := jpegBytes() - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImagePlaylistID).To(Equal(testID("pl1"))) - Expect(fp.setImageBytes).To(Equal(body)) - Expect(fp.setImageExt).To(Equal(".jpeg")) - }) - - It("base64-decodes the body and derives the extension from the actual format, not Content-Type", func() { - raw := pngBytes() - encoded := base64.StdEncoding.EncodeToString(raw) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader([]byte(encoded))) - r.Header.Set("Content-Type", "image/jpeg") // lies: the payload is a PNG - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImageBytes).To(Equal(raw)) - Expect(fp.setImageExt).To(Equal(".png")) - }) - - It("returns 501 for a non-playlist item, draining the body first", func() { - fp.getByIDPls = nil - fp.getByIDErr = model.ErrNotFound - bodyReader := bytes.NewReader([]byte("some-bytes-that-must-be-drained")) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", bodyReader) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNotImplemented)) - Expect(bodyReader.Len()).To(Equal(0)) - }) - - It("returns 500 when the service fails", func() { - fp.setImageErr = errors.New("boom") - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - - It("accepts a raw WebP body", func() { - body := webpBytes() - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/webp") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImageBytes).To(Equal(body)) - Expect(fp.setImageExt).To(Equal(".webp")) - }) - - It("accepts a raw GIF body", func() { - body := gifBytes() - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/gif") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImageBytes).To(Equal(body)) - Expect(fp.setImageExt).To(Equal(".gif")) - }) - - It("rejects an oversized body with 400, like the native endpoint", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.MaxImageUploadSize = "16" // 16 bytes - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(fp.setImagePlaylistID).To(BeEmpty(), "must not persist an over-limit upload") - }) - - It("applies the size limit to the decoded image, not the base64 body", func() { - DeferCleanup(configtest.SetupConfig()) - img := pngBytes() - // The raw image is exactly at the limit; its base64 form is 4/3 bigger. - conf.Server.MaxImageUploadSize = strconv.Itoa(len(img)) - body := []byte(base64.StdEncoding.EncodeToString(img)) - Expect(len(body)).To(BeNumerically(">", len(img))) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/png") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImageBytes).To(Equal(img)) - }) - - It("rejects a base64 body whose decoded image exceeds the limit with 400", func() { - DeferCleanup(configtest.SetupConfig()) - img := pngBytes() - conf.Server.MaxImageUploadSize = strconv.Itoa(len(img) - 1) - body := []byte(base64.StdEncoding.EncodeToString(img)) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/png") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(fp.setImagePlaylistID).To(BeEmpty()) - }) - - It("rejects a body that is neither an image nor base64 with 400", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", strings.NewReader("!!not base64!!")) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(fp.setImagePlaylistID).To(BeEmpty()) - }) - - It("rejects bytes that sniff as an image but don't decode (e.g. a truncated or renamed file)", func() { - body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} // JPEG magic, not a JPEG - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(fp.setImagePlaylistID).To(BeEmpty()) - }) - - It("forbids a non-admin upload when artwork upload is disabled", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableArtworkUpload = false - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("u1"), IsAdmin: false})) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - Expect(fp.setImagePlaylistID).To(BeEmpty()) - }) - - It("still allows an admin upload when artwork upload is disabled", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableArtworkUpload = false - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("admin"), IsAdmin: true})) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - }) -}) - -var _ = Describe("deleteItemImage", func() { - It("removes the playlist image and returns 204", func() { - fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}} - api := &Router{playlists: fp} - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.deleteItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removeImagePlaylistID).To(Equal(testID("pl1"))) - }) - - It("returns 501 for a non-playlist item", func() { - fp := &fakePlaylists{getByIDErr: model.ErrNotFound} - api := &Router{playlists: fp} - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1"))) - - api.deleteItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNotImplemented)) - }) - - It("returns 500 when the service fails", func() { - fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}, removeImageErr: errors.New("boom")} - api := &Router{playlists: fp} - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.deleteItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) -}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go deleted file mode 100644 index 28de4c31e..000000000 --- a/server/jellyfin/items.go +++ /dev/null @@ -1,1106 +0,0 @@ -package jellyfin - -import ( - "context" - "errors" - "io" - "iter" - "net/http" - "slices" - "strconv" - "strings" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/filter" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" - "golang.org/x/sync/errgroup" -) - -// notMissing excludes items whose backing files are all gone ("missing" is a real column on -// album, artist and media_file). -var notMissing = squirrel.Eq{"missing": false} - -// searchTerm trims, so a whitespace-only term is not a search: doSearch would read it as "match -// everything" and materialize the library, where the unfiltered path streams. -func searchTerm(p *req.Values) string { - return strings.TrimSpace(p.StringOr("searchterm", "")) -} - -// itemFilters is the parsed Filters=... list together with the standalone isFavorite/isPlayed params -// clients may send instead. A nil field means the client asked for no filtering on that dimension. -type itemFilters struct { - favorite *bool - played *bool -} - -// parseItemFilters reads the standalone params first and lets the Filters list win, matching real -// Jellyfin. Tokens with no Navidrome equivalent (Likes, IsFolder, IsResumable) are dropped. -func parseItemFilters(p *req.Values) itemFilters { - f := itemFilters{favorite: p.BoolPtr("isfavorite"), played: p.BoolPtr("isplayed")} - for token := range strings.SplitSeq(p.StringOr("filters", ""), ",") { - switch strings.TrimSpace(token) { - case "IsFavorite", "IsFavoriteOrLikes": - f.favorite = new(true) - case "IsPlayed": - f.played = new(true) - case "IsUnplayed": - f.played = new(false) - } - } - return f -} - -// predicates renders the filters as annotation-column conditions. The negative cases have to match -// NULL as well: annotations are LEFT JOINed, so an item nobody has touched has no row at all. -func (f itemFilters) predicates() []squirrel.Sqlizer { - var out []squirrel.Sqlizer - if f.favorite != nil { - if *f.favorite { - out = append(out, squirrel.Eq{"starred": true}) - } else { - out = append(out, squirrel.Or{squirrel.Eq{"starred": nil}, squirrel.Eq{"starred": false}}) - } - } - if f.played != nil { - if *f.played { - out = append(out, squirrel.Gt{"play_count": 0}) - } else { - out = append(out, squirrel.Or{squirrel.Eq{"play_count": nil}, squirrel.Eq{"play_count": 0}}) - } - } - return out -} - -func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { - res, err := api.queryItems(r.Context(), r) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - api.internalError(w, r, err) - return - } - api.ok(w, r, res) -} - -// itemsResult is the outcome of a collection query: a materialized page, or a cursor opener so a -// full-library response never builds every DTO at once. Exactly one of items/openCursor is set. -// -// openCursor is deferred rather than opened here: it must run after the ServerId lookup, which -// writes to the DB on first use and would deadlock against an open reader, but before the first -// response byte, so a failed open is still a clean error rather than a truncated 200. -type itemsResult struct { - items []dto.BaseItemDto - openCursor func() (iter.Seq2[dto.BaseItemDto, error], error) - total int - start int -} - -func materialized(q dto.QueryResult) itemsResult { - return itemsResult{items: q.Items, total: q.TotalRecordCount, start: q.StartIndex} -} - -func streamed(open func() (iter.Seq2[dto.BaseItemDto, error], error), total, start int) itemsResult { - return itemsResult{openCursor: open, total: total, start: start} -} - -// chained streams several results back to back, skipping the first skip items — the unbounded -// multi-type merge, where paginate(items, offset, 0) is just the concatenation minus its head. -func chained(results []itemsResult, total, skip int) itemsResult { - open := func() (iter.Seq2[dto.BaseItemDto, error], error) { - if len(results) == 0 { - return sliceItems(nil), nil - } - // Only the first opens eagerly (so the usual failure is still a clean error); the rest open as - // the stream reaches them, so only one cursor pins a DB connection at a time. - first, err := results[0].seq() - if err != nil { - return nil, err - } - return func(yield func(dto.BaseItemDto, error) bool) { - n := 0 - emit := func(seq iter.Seq2[dto.BaseItemDto, error]) bool { - for it, err := range seq { - if err != nil { - yield(dto.BaseItemDto{}, err) - return false - } - if n < skip { - n++ - continue - } - if !yield(it, nil) { - return false - } - } - return true - } - if !emit(first) { - return - } - for _, res := range results[1:] { - seq, err := res.seq() - if err != nil { - yield(dto.BaseItemDto{}, err) - return - } - if !emit(seq) { - return - } - } - }, nil - } - return streamed(open, total, skip) -} - -// streamCursor builds a deferred opener that maps each row as it's yielded. It takes the cursor's -// underlying func type, so callers wrap repo.GetCursor for the named type to infer T. -func streamCursor[T any](openCursor func() (func(func(T, error) bool), error), toItem func(T) dto.BaseItemDto) func() (iter.Seq2[dto.BaseItemDto, error], error) { - return func() (iter.Seq2[dto.BaseItemDto, error], error) { - cursor, err := openCursor() - if err != nil { - return nil, err - } - return func(yield func(dto.BaseItemDto, error) bool) { - for row, err := range cursor { - if err != nil { - yield(dto.BaseItemDto{}, err) - return - } - if !yield(toItem(row), nil) { - return - } - } - }, nil - } -} - -// seq returns the items as one sequence, opening the cursor if there is one. -func (ir itemsResult) seq() (iter.Seq2[dto.BaseItemDto, error], error) { - if ir.openCursor != nil { - return ir.openCursor() - } - return sliceItems(ir.items), nil -} - -// collect drains the result into a slice, for the merge that combines types before paginating. -func (ir itemsResult) collect() ([]dto.BaseItemDto, error) { - if ir.openCursor == nil { - return ir.items, nil - } - seq, err := ir.openCursor() - if err != nil { - return nil, err - } - var out []dto.BaseItemDto - for it, err := range seq { - if err != nil { - return nil, err - } - out = append(out, it) - } - return out, nil -} - -func (api *Router) writeItems(w http.ResponseWriter, r *http.Request, res itemsResult) { - api.streamResult(w, r, res, func(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { - return streamItemsEnvelope(w, items, res.total, res.start) - }) -} - -// writeItemsArray writes the bare-array shape (/Items/Latest), which has no QueryResult envelope. -func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res itemsResult) { - api.streamResult(w, r, res, streamItemsArray) -} - -// stampItem fills in what real Jellyfin puts on every item it returns, so a client that requires a -// key never meets an item without it: ServerId, MediaType, ImageTags, and the Fields-gated lists. -func stampItem(it dto.BaseItemDto, serverID string, fields dto.Fields) dto.BaseItemDto { - it.ServerId = serverID - if it.MediaType == "" { - it.MediaType = "Unknown" - } - if it.ImageTags == nil { - it.ImageTags = map[string]string{} - } - if fields.Has("Genres") { - if it.Genres == nil { - it.Genres = []string{} - } - if it.GenreItems == nil { - it.GenreItems = []dto.NameGuidPair{} - } - } - if fields.Has("Tags") && it.Tags == nil { - it.Tags = []string{} - } - return it -} - -// requestFields parses the Fields param, which gates what stampItem and the mappers attach. -func requestFields(r *http.Request) dto.Fields { - return dto.ParseFields(req.Params(r).Strings("fields")...) -} - -// streamResult stamps every item (see stampItem). The cursor opens before the first byte, so a -// failed open is still a clean 500. -func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult, - write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) { - sid, fields := api.serverID(r.Context()), requestFields(r) - seq, err := res.seq() - if err != nil { - api.internalError(w, r, err) - return - } - stamped := func(yield func(dto.BaseItemDto, error) bool) { - for it, err := range seq { - if err != nil { - yield(dto.BaseItemDto{}, err) - return - } - if !yield(stampItem(it, sid, fields), nil) { - return - } - } - } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - if err := write(w, stamped); err != nil { - log.Error(r.Context(), "Jellyfin API: error streaming response", err) - } -} - -// itemsQuery is a parsed /Items request, so the dispatch and every listXxx take one value instead -// of a long positional parameter list. -type itemsQuery struct { - fields dto.Fields - ids []string - rawTypes string - types []string - search string - sortBy string - sortOrder string - offset int - limit int - filters itemFilters - // parentId scopes the query. entityParent is the same id only when it names an entity (an artist - // for MusicAlbum, an album for Audio) rather than a library. - parentId string - entityParent string - isLibraryParent bool - scopeIDs []int - // artistId selects that artist's own discography; contributingOnly means albums they merely - // appear on (Jellyfin's "Featured On"), which must exclude that discography. - artistId string - contributingOnly bool - genreIds []string - albumIds []string - years []int - studioIds []string -} - -// listParams reads the itemsQuery fields that come straight from query params. -func listParams(p *req.Values) itemsQuery { - return itemsQuery{ - fields: dto.ParseFields(p.Strings("fields")...), - search: searchTerm(p), - sortBy: p.StringOr("sortby", ""), - sortOrder: p.StringOr("sortorder", ""), - offset: p.IntOr("startindex", 0), - limit: p.IntOr("limit", 0), - filters: parseItemFilters(p), - } -} - -// parseItemsQuery also resolves the entity types (inferring them from the parent when -// IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because -// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). A non-empty id param that -// fails to decode reports model.ErrNotFound rather than silently dropping the filter (see decodeFilterParam). -func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQuery, error) { - p := req.Params(r) - parentId, ok := decodeFilterParam(p.StringOr("parentid", "")) - if !ok { - return itemsQuery{}, model.ErrNotFound - } - // Any malformed entry in one of these id lists must 404, not silently drop out of the filter - // (see dto.DecodeIDs) — an all-malformed list would otherwise widen the query to everything. - ids, ok := decodedQueryIDs(r, "ids") - if !ok { - return itemsQuery{}, model.ErrNotFound - } - // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. - genreIds, ok := decodedQueryIDs(r, "genreids") - if !ok { - return itemsQuery{}, model.ErrNotFound - } - // Feishin fetches an album's tracks with AlbumIds instead of ParentId. - albumIds, ok := decodedQueryIDs(r, "albumids") - if !ok { - return itemsQuery{}, model.ErrNotFound - } - studioIds, ok := decodedQueryIDs(r, "studioids") - if !ok { - return itemsQuery{}, model.ErrNotFound - } - q := listParams(p) - q.ids = ids - q.rawTypes = knownItemKinds(p.StringOr("includeitemtypes", "")) - q.parentId = parentId - q.genreIds = genreIds - q.albumIds = albumIds - q.years = parseYears(r) - q.studioIds = studioIds - // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping - // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. - albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) - contributingScope := p.StringOr("contributingartistids", "") - artistId, ok := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) - if !ok { - return itemsQuery{}, model.ErrNotFound - } - q.artistId = artistId - q.contributingOnly = albumArtistScope == "" && contributingScope != "" - - q.types = parseTypes(q.rawTypes) - q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId) - - // Recursive=false asks for direct children only, and no track is a library's direct child. - // Finamp's sync probes a library this way, and every track is a wrong, unbounded answer. - if q.isLibraryParent && !p.BoolOr("recursive", false) { - q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" }) - } - - // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks - // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse - // its albums). - if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent { - if q.parentId == dto.PlaylistsFolderID { - // Browsing into the synthetic playlists folder lists the user's playlists. - q.types = []string{"Playlist"} - } else if _, err := api.ds.Album().Get(ctx, q.parentId); err == nil { - q.types = []string{"Audio"} - } - } - // ParentId-as-entity-id only makes sense for a single type; a multi-type query has no natural - // parent entity, so there ParentId is only library scoping. - q.entityParent = q.parentId - if q.isLibraryParent || len(q.types) > 1 { - q.entityParent = "" - } - return q, nil -} - -// queryItems is the /Items dispatcher: it resolves the request to entity types and queries each via -// the matching listXxx, merging multi-type results into one paginated list (as Finamp's favorites -// screen requests). -func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult, error) { - q, err := api.parseItemsQuery(ctx, r) - if err != nil { - return itemsResult{}, err - } - switch { - // /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch. - case len(q.ids) > 0: - return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil - // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. - case strings.Contains(strings.ToLower(q.rawTypes), "manualplaylistsfolder"): - return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil - } - if repo, ok := api.playlistTracksRepo(ctx, q); ok { - return api.playlistTrackPage(ctx, repo, q.fields, q.offset, q.limit) - } - if q.search != "" { - q.limit = clampLimit(q.limit, defaultSearchLimit, maxSearchLimit) - } - if len(q.types) == 1 { - opts := model.QueryOptions{Offset: q.offset, Max: q.limit} - applySort(&opts, q.types[0], q.sortBy, q.sortOrder) - return api.queryItemsOfType(ctx, q.types[0], opts, q) - } - return api.mergeTypes(ctx, q) -} - -// playlistTracksRepo resolves a playlist parent, whatever IncludeItemTypes says: Jellify opens a -// playlist with ParentId=&IncludeItemTypes=Audio, and routing that through listSongs would -// treat the playlist id as an album id and return nothing. -// -// ok is false when ParentId isn't a visible playlist, so the caller falls through to the type -// dispatch: ParentId is usually an album or artist. -func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) { - if q.parentId == "" || q.isLibraryParent || q.parentId == dto.PlaylistsFolderID { - return nil, false - } - // Tracks enforces visibility. - repo, err := api.playlists.Tracks(ctx, q.parentId) - return repo, err == nil -} - -func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) { - if q.limit == 0 { - return api.mergeTypesStreaming(ctx, q) - } - // A random page doesn't stack on the previous one (the order reshuffles each request), so serving - // from 0 is an equivalent fresh draw and avoids materializing offset+limit rows per type. - offset := q.offset - if randomlySorted(q) { - offset = 0 - } - return api.mergeTypesPaged(ctx, q, offset) -} - -// randomlySorted reports whether every merged type resolves to a random sort — the case where a page -// is an independent draw, so the offset can be collapsed to 0. Resolving via applySort (rather than -// matching the raw SortBy) keeps this in step with how each type's sort is actually chosen. -func randomlySorted(q itemsQuery) bool { - for _, itemType := range q.types { - var opts model.QueryOptions - applySort(&opts, itemType, q.sortBy, q.sortOrder) - if opts.Sort != "random" { - return false - } - } - return true -} - -// mergeTypesStreaming keeps the unbounded path lazy: chaining the per-type cursors yields their rows -// in order minus the first offset, without pulling every row into memory. -func (api *Router) mergeTypesStreaming(ctx context.Context, q itemsQuery) (itemsResult, error) { - var results []itemsResult - total := 0 - for _, itemType := range q.types { - res, err := api.queryTypeWindow(ctx, itemType, 0, q) - if err != nil { - return itemsResult{}, err - } - results = append(results, res) - total += res.total - } - return chained(results, total, q.offset), nil -} - -// queryTypeWindow queries one type for the merge paths, capping it to window rows with the sort applied. -func (api *Router) queryTypeWindow(ctx context.Context, itemType string, window int, q itemsQuery) (itemsResult, error) { - var opts model.QueryOptions - opts.Max = window - applySort(&opts, itemType, q.sortBy, q.sortOrder) - return api.queryItemsOfType(ctx, itemType, opts, q) -} - -// mergeTypesPaged runs each type's query concurrently, then round-robins the per-type rows so the limited page -// is a mix rather than one type's rows followed by the next. -func (api *Router) mergeTypesPaged(ctx context.Context, q itemsQuery, offset int) (itemsResult, error) { - // Each per-type query needs at most offset+limit rows (worst case: one type fills the whole window). - window := offset + q.limit - if q.search != "" { - window = min(window, maxSearchLimit) - } - lists := make([][]dto.BaseItemDto, len(q.types)) - totals := make([]int, len(q.types)) - g, ctx := errgroup.WithContext(ctx) - for i, itemType := range q.types { - g.Go(func() error { - res, err := api.queryTypeWindow(ctx, itemType, window, q) - if err != nil { - return err - } - items, err := res.collect() - if err != nil { - return err - } - lists[i] = items - totals[i] = res.total - return nil - }) - } - if err := g.Wait(); err != nil { - return itemsResult{}, err - } - total := 0 - for _, t := range totals { - total += t - } - items := interleave(lists) - if q.search != "" { - // Past the window the merged order isn't the true one, so drop it rather than serve another - // type's rows. The total is what's pageable overall, so a client paging on it won't stop early. - items = items[:min(window, len(items))] - total = min(total, maxSearchLimit) - } - return materialized(result(paginate(items, offset, q.limit), total, q.offset)), nil -} - -func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { - switch itemType { - case "Audio": - return api.listSongs(ctx, opts, q) - case "MusicArtist": - // The MusicArtist browse hierarchy (UserViews -> artists -> albums) means album artists. - return api.listArtists(ctx, opts, q, model.RoleAlbumArtist) - case "MusicGenre": - return api.listGenres(ctx, opts) - case "Playlist": - return api.listPlaylists(ctx, opts, q) - default: // MusicAlbum - return api.listAlbums(ctx, opts, q) - } -} - -// firstNonEmpty returns the first non-empty string, or "". -func firstNonEmpty(vals ...string) string { - for _, v := range vals { - if v != "" { - return v - } - } - return "" -} - -// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list, reporting -// whether it decoded successfully (see decodeFilterParam). -func firstDecodedID(s string) (string, bool) { - if s == "" { - return "", true - } - first, _, _ := strings.Cut(s, ",") - return decodeFilterParam(strings.TrimSpace(first)) -} - -// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs). ok is false if -// any entry is malformed, so a dropped entry can't shrink the list into an empty, no-op filter. -func decodedQueryIDs(r *http.Request, key string) ([]string, bool) { - return dto.DecodeIDs(queryIDs(r, key)) -} - -// parseYears reads Years= as a discrete list, accepting comma-separated and repeated params. -func parseYears(r *http.Request) []int { - var years []int - for _, v := range queryIDs(r, "years") { - if y, err := strconv.Atoi(v); err == nil && y > 0 { - years = append(years, y) - } - } - return years -} - -// supportedTypes maps lowercased IncludeItemTypes names (Jellyfin binds them case-insensitively) -// to the item types Navidrome serves. -var supportedTypes = map[string]string{ - "audio": "Audio", "musicartist": "MusicArtist", "musicalbum": "MusicAlbum", "musicgenre": "MusicGenre", "playlist": "Playlist", -} - -// jellyfinItemKinds lists Jellyfin's BaseItemKind names, lowercased. -var jellyfinItemKinds = map[string]bool{ - "aggregatefolder": true, "audio": true, "audiobook": true, "basepluginfolder": true, "book": true, - "boxset": true, "channel": true, "channelfolderitem": true, "collectionfolder": true, "episode": true, - "folder": true, "genre": true, "manualplaylistsfolder": true, "movie": true, "livetvchannel": true, - "livetvprogram": true, "musicalbum": true, "musicartist": true, "musicgenre": true, "musicvideo": true, - "person": true, "photo": true, "photoalbum": true, "playlist": true, "playlistsfolder": true, - "program": true, "recording": true, "season": true, "series": true, "studio": true, "trailer": true, - "tvchannel": true, "tvprogram": true, "userrootfolder": true, "userview": true, "video": true, "year": true, -} - -// knownItemKinds drops IncludeItemTypes entries that aren't BaseItemKind names, as Jellyfin's binder -// does, so an all-unknown list (JellyBox sends "music") behaves like an absent one. -func knownItemKinds(types string) string { - var known []string - for t := range strings.SplitSeq(types, ",") { - if t = strings.TrimSpace(t); jellyfinItemKinds[strings.ToLower(t)] { - known = append(known, t) - } - } - return strings.Join(known, ",") -} - -// parseTypes returns the supported entries in IncludeItemTypes in order. Only an absent param -// defaults to albums, so ParentId= still browses that artist's albums. -func parseTypes(types string) []string { - if strings.TrimSpace(types) == "" { - return []string{"MusicAlbum"} - } - var recognized []string - for t := range strings.SplitSeq(types, ",") { - if name, ok := supportedTypes[strings.ToLower(strings.TrimSpace(t))]; ok { - recognized = append(recognized, name) - } - } - // Dedupe: a repeated type would duplicate items in the merge and spawn a redundant query. - return slice.Unique(recognized) -} - -// paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only -// (single-type queries push Offset/Max down to SQL instead). -func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { - if offset >= len(items) { - return []dto.BaseItemDto{} - } - items = items[offset:] - if limit > 0 && limit < len(items) { - items = items[:limit] - } - return items -} - -// interleave merges per-type item lists round-robin: one item from each list in turn, preserving -// each list's own order, so no single type dominates the head of a mixed-type result. -func interleave(lists [][]dto.BaseItemDto) []dto.BaseItemDto { - total, maxLen := 0, 0 - for _, l := range lists { - total += len(l) - maxLen = max(maxLen, len(l)) - } - out := make([]dto.BaseItemDto, 0, total) - for i := 0; i < maxLen; i++ { - for _, l := range lists { - if i < len(l) { - out = append(out, l[i]) - } - } - } - return out -} - -// Search can't stream (Search returns a slice), so it needs both a default and a ceiling: without -// the ceiling, Limit=999999 still materializes every match. -const ( - defaultSearchLimit = 100 - maxSearchLimit = 2000 -) - -// clampLimit bounds a client-supplied limit, 0 or less meaning it sent none, so it can't drive an -// oversized allocation or provider fetch (flagged by CodeQL as a user-controlled allocation size). -// -// Searches clamp their Limit here rather than in searchPage, which also sees mergeTypes' larger -// offset+limit window: bounding that would truncate each type before the merged page is cut. -func clampLimit(limit, def, ceiling int) int { - if limit <= 0 { - return def - } - return min(limit, ceiling) -} - -// searchPage runs a repository Search fetching one extra row to derive TotalRecordCount, since the -// Search API returns no match count and CountAll can't see the search term. offset+len(rows) is -// exact once matches end (and a growing lower bound before), so paging terminates at the last match. -func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) { - fetch := opts - fetch.Max++ - rows, err := search(fetch) - if err != nil { - return nil, 0, err - } - total := opts.Offset + len(rows) - if len(rows) > opts.Max { - rows = rows[:opts.Max] - } - return rows, total, nil -} - -func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { - toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) } - repo := api.ds.Album() - filters := squirrel.And{} - // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's - // albums"; contributingArtistIds means "albums they only appear on" (Featured On). - switch { - case q.contributingOnly && q.artistId != "": - filters = append(filters, filter.AlbumsByContributingArtistID(q.artistId).Filters) - case firstNonEmpty(q.artistId, q.entityParent) != "": - filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(q.artistId, q.entityParent)).Filters) - default: - filters = append(filters, notMissing) - } - if len(q.genreIds) > 0 { - filters = append(filters, filter.AlbumsByGenreID(q.genreIds)) - } - if len(q.years) > 0 { - filters = append(filters, filter.AlbumsByYears(q.years)) - } - if len(q.studioIds) > 0 { - filters = append(filters, filter.ByStudioID(q.studioIds)) - } - // Not on the search path: its first FTS phase selects rowids with no annotation join, so a - // starred/play_count predicate there is "no such column" rather than a filter. - if q.search == "" { - filters = append(filters, q.filters.predicates()...) - } - opts.Filters = filters - opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) - - if q.search != "" { - albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) { - return repo.Search(ctx, q.search, o) - }) - if err != nil { - return itemsResult{}, err - } - return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil - } - total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) - open := streamCursor(func() (func(func(model.Album, error) bool), error) { - return repo.GetCursor(ctx, opts) - }, toItem) - return streamed(open, int(total), opts.Offset), nil -} - -func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { - toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, q.fields) } - repo := api.ds.MediaFile() - filters := squirrel.And{} - // For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's. - switch { - case q.artistId != "": - filters = append(filters, filter.SongsByArtistID(q.artistId).Filters) - case q.entityParent != "": - filters = append(filters, filter.SongsByAlbum(q.entityParent).Filters) - default: - filters = append(filters, notMissing) - } - if len(q.albumIds) > 0 { - filters = append(filters, filter.ByAlbumID(q.albumIds)) - } - if len(q.genreIds) > 0 { - filters = append(filters, filter.SongsByGenreID(q.genreIds)) - } - if len(q.years) > 0 { - filters = append(filters, filter.SongsByYears(q.years)) - } - if len(q.studioIds) > 0 { - filters = append(filters, filter.ByStudioID(q.studioIds)) - } - // Not on the search path: its first FTS phase selects rowids with no annotation join, so a - // starred/play_count predicate there is "no such column" rather than a filter. - if q.search == "" { - filters = append(filters, q.filters.predicates()...) - } - opts.Filters = filters - opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) - - if q.search != "" { - mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) { - return repo.Search(ctx, q.search, o) - }) - if err != nil { - return itemsResult{}, err - } - return materialized(result(slice.Map(mfs, toItem), total, opts.Offset)), nil - } - // When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an - // explicit client SortBy still wins, since applySort already set opts.Sort. - if q.artistId == "" && q.entityParent != "" && opts.Sort == "" { - opts.Sort = filter.SongsByAlbum(q.entityParent).Sort - } - // A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows. - total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) - open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) { - return repo.GetCursorWithArtwork(ctx, opts) - }, toItem) - return streamed(open, int(total), opts.Offset), nil -} - -// listArtists lists artists in the given role: RoleAlbumArtist for the "album artists" views, -// RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical. -// genreIds isn't applied to search — a name lookup, like role (see below). -func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) { - repo := api.ds.Artist() - toItem := func(ar model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(ar, q.fields) } - - // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a - // search scope (artists have no library_id column). A compound or join-based filter - // (ApplyArtistLibraryFilter) would leak into the FTS query and 500, so search and browse build - // filters differently. Role isn't applied to search for the same reason — it's a name lookup. - if q.search != "" { - if len(q.scopeIDs) > 0 { - opts.Filters = squirrel.Eq{"library_id": q.scopeIDs} - } - artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) { - return repo.Search(ctx, q.search, o) - }) - if err != nil { - return itemsResult{}, err - } - return materialized(result(slice.Map(artists, toItem), total, opts.Offset)), nil - } - - filters := squirrel.And{notMissing} - filters = append(filters, q.filters.predicates()...) - if len(q.genreIds) > 0 { - filters = append(filters, filter.ArtistsByGenreID(q.genreIds)) - } - opts.Filters = filters - opts = filter.ArtistsByRole(opts, role) - opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs) - total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) - open := streamCursor(func() (func(func(model.Artist, error) bool), error) { - return repo.GetCursor(ctx, opts) - }, toItem) - return streamed(open, int(total), opts.Offset), nil -} - -// listGenres is intentionally unscoped: genres are global tags, not per-library entities. It's also -// the one listXxx that stays materialized: GenreRepository has no CountAll, so the total is the -// length of the full list and paging is in-memory — nothing for a cursor to page over. -func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (itemsResult, error) { - genres, err := api.ds.Genre().GetAll(ctx, model.QueryOptions{Sort: opts.Sort, Order: opts.Order}) - if err != nil { - return itemsResult{}, err - } - items := slice.Map(genres, dto.GenreToBaseItem) - return materialized(result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset)), nil -} - -// listPlaylists lists playlists visible to the current user. Visibility (public or owned) is -// enforced by playlistRepository, not scopeIDs. -func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { - if preds := q.filters.predicates(); len(preds) > 0 { - opts.Filters = squirrel.And(preds) - } - repo := api.ds.Playlist() - total, err := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) - if err != nil { - return itemsResult{}, err - } - open := streamCursor(func() (func(func(model.Playlist, error) bool), error) { - return repo.GetCursor(ctx, opts) - }, func(p model.Playlist) dto.BaseItemDto { return dto.PlaylistToBaseItem(p, q.fields) }) - return streamed(open, int(total), opts.Offset), nil -} - -// resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, -// artist, song, playlist and genre in turn. Albums and songs report not-found when the user lacks access -// to their library, so an id can't probe content outside the user's libraries. -func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) { - // The synthetic playlists folder must resolve by the id we advertised, not 404. - if id == dto.PlaylistsFolderID { - return playlistsFolder(), true - } - u, _ := request.UserFrom(ctx) - // Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this - // the home screen and library tabs 404. - if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) { - if lib, err := api.ds.Library().Get(ctx, libID); err == nil { - return dto.LibraryToBaseItem(*lib), true - } - } - if al, err := api.ds.Album().Get(ctx, id); err == nil { - if !u.HasLibraryAccess(al.LibraryID) { - return dto.BaseItemDto{}, false - } - return dto.AlbumToBaseItem(*al, fields), true - } - if ar, err := api.ds.Artist().Get(ctx, id); err == nil { - // Artist.Get already scopes to the user's libraries via library_artist. - return dto.ArtistToBaseItem(*ar, fields), true - } - if mf, err := api.ds.MediaFile().Get(ctx, id); err == nil { - if !u.HasLibraryAccess(mf.LibraryID) { - return dto.BaseItemDto{}, false - } - return dto.SongToBaseItem(*mf, fields), true - } - // api.playlists.Get enforces ownership/visibility, so a non-owned or missing id falls through. - if pl, err := api.playlists.Get(ctx, id); err == nil { - return dto.PlaylistToBaseItem(*pl, fields), true - } - if g, err := api.ds.Genre().Get(ctx, id); err == nil { - return dto.GenreToBaseItem(*g), true - } - return dto.BaseItemDto{}, false -} - -// songsByIDs fetches the media files among ids with chunked IN queries instead of a Get per id. -func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]model.MediaFile { - songs := make(map[string]model.MediaFile, len(ids)) - // Chunked to stay under SQLITE_MAX_VARIABLE_NUMBER, like playqueue's loadTracks. - for chunk := range slice.CollectChunks(slices.Values(ids), 500) { - mfs, err := api.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"media_file.id": chunk}}) - if err != nil { - log.Error(ctx, "Jellyfin API: error fetching songs by id", err) - continue - } - for _, mf := range mfs { - songs[mf.ID] = mf - } - } - return songs -} - -// itemsByIDs resolves a decoded id list, keeping input order and skipping unresolvable ids. -func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult { - u, _ := request.UserFrom(ctx) - songs := api.songsByIDs(ctx, ids) - var items []dto.BaseItemDto - for _, id := range ids { - var item dto.BaseItemDto - if mf, ok := songs[id]; ok { - if !u.HasLibraryAccess(mf.LibraryID) { - continue - } - item = dto.SongToBaseItem(mf, fields) - } else if item, ok = api.resolveItemByID(ctx, id, fields); !ok { - continue - } - items = append(items, item) - } - return result(items, len(items), 0) -} - -func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - fields := dto.ParseFields(req.Params(r).Strings("fields")...) - if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { - api.ok(w, r, item) - return - } - http.Error(w, "Not Found", http.StatusNotFound) -} - -// deleteItem handles DELETE /Items/{id}. Only playlists are deletable here (albums/songs come from -// scanning), so a non-playlist id 404s. core/playlists.Delete enforces ownership. -func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - if err := api.playlists.Delete(ctx, id); err != nil { - api.playlistError(w, r, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// getLatest returns a bare array, not a QueryResult envelope — real Jellyfin's shape for -// /Items/Latest, and why it writes directly instead of going through api.ok. -func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - p := req.Params(r) - fields := dto.ParseFields(p.Strings("fields")...) - opts := filter.AlbumsByNewest() - opts.Max = p.IntOr("limit", 20) - parentID, ok := decodeFilterParam(p.StringOr("parentid", "")) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - // A ParentId naming neither a library nor an artist (a stale id, an album) narrows to nothing - // rather than widening back to every library. - scopeIDs, isLibrary := resolveLibraryScope(ctx, parentID) - if parentID != "" && !isLibrary { - opts.Filters = squirrel.And{opts.Filters, filter.AlbumsByArtistID(parentID).Filters} - } - opts = filter.ApplyLibraryFilter(opts, scopeIDs) - repo := api.ds.Album() - open := streamCursor(func() (func(func(model.Album, error) bool), error) { - return repo.GetCursor(ctx, opts) - }, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) }) - api.writeItemsArray(w, r, streamed(open, 0, 0)) -} - -func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { - if items == nil { - items = []dto.BaseItemDto{} - } - return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start} -} - -// applySort keeps every recognized SortBy key, so secondary keys break ties as Jellyfin intends. -// Unrecognized keys are skipped, not passed through raw where they could make an invalid ORDER BY. -func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { - var cols []string - for key := range strings.SplitSeq(sortBy, ",") { - col, ok := sortColumn(itemType, strings.TrimSpace(key)) - // The repo matches random by exact string equality, so it can only ever sort alone. - if !ok || slices.Contains(cols, col) || (col == "random" && len(cols) > 0) { - continue - } - cols = append(cols, col) - if col == "random" { - break - } - } - switch { - case len(cols) > 0: - opts.Sort = strings.Join(cols, ", ") - case sortBy != "": - log.Debug("Jellyfin API: no usable SortBy key, falling back to the default order", - "itemType", itemType, "sortBy", sortBy) - } - // Jellyfin allows a per-key SortOrder list, which one Order can't express; honor the first value - // for every key, as Jellyfin does for keys past the end of the list. - first, _, _ := strings.Cut(order, ",") - if strings.EqualFold(first, "Descending") { - opts.Order = "desc" - } -} - -// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type (repos map logical fields -// to different real columns, e.g. media_file has "title" not "name"). -var sortColumnsByType = map[string]map[string]string{ - "Audio": { - "sortname": "title", "name": "title", - "album": "album", - // Finamp's album view sorts by ParentIndexNumber,IndexNumber (disc, track); Navidrome's - // "album" sort key is disc+track order within an album, so map both to it. - "indexnumber": "album", - "parentindexnumber": "album", - "artist": "artist", - "albumartist": "album_artist", - "datecreated": "recently_added", - "playcount": "play_count", - "dateplayed": "play_date", - "communityrating": "rating", - "random": "random", - "runtime": "duration", - "runtimeticks": "duration", - // Finamp's "Latest Releases" sorts by PremiereDate; "year" matches songs' ProductionYear. - "premieredate": "year", - "productionyear": "year", - }, - "MusicArtist": { - "sortname": "name", "name": "name", - "albumcount": "album_count", - "songcount": "song_count", - "datecreated": "created_at", - "playcount": "play_count", - "dateplayed": "play_date", - "communityrating": "rating", - "random": "random", - }, - "MusicAlbum": { - "sortname": "name", "name": "name", "album": "name", - "artist": "artist", - "albumartist": "album_artist", - "datecreated": "recently_added", - "random": "random", - "playcount": "play_count", - "dateplayed": "play_date", - "communityrating": "rating", - "runtime": "duration", - "runtimeticks": "duration", - "premieredate": "max_year", "productionyear": "max_year", - }, - "MusicGenre": { - "sortname": "name", "name": "name", - "random": "random", - }, - "Playlist": { - "sortname": "name", "name": "name", - "datecreated": "created_at", - "random": "random", - }, -} - -// sortColumn maps a single (non comma-list) Jellyfin SortBy key to the repo sort key for -// itemType, reporting false when it isn't recognized for that type. -func sortColumn(itemType, sortBy string) (string, bool) { - col, ok := sortColumnsByType[itemType][strings.ToLower(sortBy)] - return col, ok -} diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go deleted file mode 100644 index 18789a34b..000000000 --- a/server/jellyfin/items_test.go +++ /dev/null @@ -1,1203 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "slices" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// withChiURLParam simulates chi's routing having captured a path parameter, since these -// tests call handlers directly instead of going through the full router. -func withChiURLParam(r *http.Request, key, value string) *http.Request { - rctx := chi.NewRouteContext() - rctx.URLParams.Add(key, value) - return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) -} - -var _ = Describe("Items", func() { - var api *Router - var ds *tests.MockDataStore - var fp *fakePlaylists - // alice has access to library 1 only; used by tests that don't care about scoping. - ctxUser := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) - } - ctxUserWithLibraries := func(libs model.Libraries) context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs}) - } - // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. - ctxAdmin := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil}) - } - BeforeEach(func() { - ds = &tests.MockDataStore{} - fp = &fakePlaylists{} - api = &Router{ds: ds, playlists: fp} - }) - - Describe("getItems", func() { - It("lists albums when IncludeItemTypes=MusicAlbum", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - Expect(res.Items[0].Type).To(Equal("MusicAlbum")) - Expect(res.TotalRecordCount).To(Equal(2)) - }) - - It("lists an album's songs when ParentId is an album and type is Audio", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Type).To(Equal("Audio")) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - }) - - It("ignores IncludeItemTypes names that aren't Jellyfin item kinds", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=music", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - }) - - It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() { - fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, - {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, - }} - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&IncludeItemTypes=Audio", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1"))) - Expect(res.TotalRecordCount).To(Equal(2)) - }) - - It("pages a playlist parent's tracks in the query, not in memory", func() { - fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, - {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, - {ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}}, - }} - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&StartIndex=1&Limit=1", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.TotalRecordCount).To(Equal(3)) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2")))) - Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) - Expect(fp.tracksRepo.Options.Max).To(Equal(1)) - }) - - It("falls through to the type dispatch when ParentId is not a playlist", func() { - fp.getErr = model.ErrNotFound - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - }) - - It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetError(true) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=true", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - - // Recursive=false asks for direct children only. Finamp's sync probes a library this way - // looking for tracks outside any album; answering with every track streams the whole library. - Describe("Recursive=false", func() { - BeforeEach(func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) - }) - - It("returns no songs for a library parent, as tracks are never its direct children", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=false", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(BeEmpty()) - Expect(res.TotalRecordCount).To(BeZero()) - }) - - It("drops only Audio from a multi-type library query", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Type).To(Equal("MusicAlbum")) - }) - - It("still lists albums for a library parent, as they are its direct children", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - }) - - It("still lists an album's tracks, as they are its direct children", func() { - fp.getErr = model.ErrNotFound - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio&Recursive=false", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - }) - - It("keeps returning every song when no parent scopes the query", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=false", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - }) - - // Jellyfin's own default: ItemsController binds `bool? recursive` and reads it as - // `recursive ?? false`, so an omitted Recursive is a non-recursive request. - It("treats an omitted Recursive as false, like Jellyfin", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(BeEmpty()) - }) - }) - - It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", AlbumArtistID: testID("ar1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("ar1"))+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - albumRepo := ds.Album().(*tests.MockAlbumRepo) - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("album_artists")) - }) - - It("lists artists when IncludeItemTypes=MusicArtist", func() { - ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Type).To(Equal("MusicArtist")) - }) - - It("lists genres when IncludeItemTypes=MusicGenre", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicGenre", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).NotTo(BeNil()) - }) - - It("lists playlists when IncludeItemTypes=Playlist", func() { - ds.Playlist().(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", SongCount: 5}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Type).To(Equal("Playlist")) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("p1")))) - Expect(res.TotalRecordCount).To(Equal(1)) - }) - - It("merges results from every requested type in IncludeItemTypes", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - types := []string{res.Items[0].Type, res.Items[1].Type} - Expect(types).To(ConsistOf("Audio", "MusicAlbum")) - Expect(res.TotalRecordCount).To(Equal(2)) - }) - - It("merges favorite songs, albums, and playlists", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo) - playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(3)) - types := []string{res.Items[0].Type, res.Items[1].Type} - types = append(types, res.Items[2].Type) - Expect(types).To(ConsistOf("Audio", "MusicAlbum", "Playlist")) - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("starred")) - playlistSQL, _, err := playlistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(playlistSQL).To(ContainSubstring("starred")) - }) - - It("applies StartIndex/Limit to the merged multi-type result set", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - Expect(res.TotalRecordCount).To(Equal(4)) - Expect(res.StartIndex).To(Equal(1)) - }) - - It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - // The merged window is [1, 3): each type needs at most its first 3 rows, not the table. - Expect(mfRepo.Options.Max).To(Equal(3)) - Expect(albumRepo.Options.Max).To(Equal(3)) - }) - - DescribeTable("translates the Filters list and its standalone equivalents", - func(query string, wantSQL, notWantSQL []string) { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&"+query, nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - for _, want := range wantSQL { - Expect(sql).To(ContainSubstring(want)) - } - for _, not := range notWantSQL { - Expect(sql).NotTo(ContainSubstring(not)) - } - }, - Entry("IsFavorite", "Filters=IsFavorite", []string{"starred"}, nil), - Entry("IsFavorite,IsUnplayed combined", "Filters=IsFavorite,IsUnplayed", - []string{"starred", "play_count"}, nil), - Entry("IsUnplayed", "Filters=IsUnplayed", []string{"play_count"}, []string{"starred"}), - Entry("IsPlayed", "Filters=IsPlayed", []string{"play_count"}, []string{"starred"}), - Entry("IsFavoriteOrLikes is treated as favorites", "Filters=IsFavoriteOrLikes", []string{"starred"}, nil), - Entry("isPlayed=false", "isPlayed=false", []string{"play_count"}, nil), - Entry("isFavorite=false still filters", "isFavorite=false", []string{"starred"}, nil), - // Jellyfin builds the query from the standalone params, then applies Filters over the top. - Entry("Filters wins over the standalone param", "isFavorite=false&Filters=IsFavorite", - []string{"starred = "}, nil), - // No Navidrome equivalent: these must be dropped, not half-applied. - Entry("Likes is ignored", "Filters=Likes", nil, []string{"starred", "play_count"}), - Entry("IsResumable is ignored", "Filters=IsResumable", nil, []string{"starred", "play_count"}), - // The artist-parent branch gets notMissing from filter.AlbumsByArtistID, not the default - // branch, so favorites must not be the only predicate left on it. - Entry("keeps missing excluded under an artist parent", - "Filters=IsFavorite&ArtistIds="+dto.EncodeID(testID("ar1")), - []string{"starred", "missing"}, nil), - ) - - // Search runs a two-phase FTS query whose first phase has no annotation join, so an - // annotation predicate there is "no such column: starred" -> 500. - DescribeTable("does not push annotation filters into a search", - func(itemType, filters string) { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", - "/Items?IncludeItemTypes="+itemType+"&SearchTerm=one&Filters="+filters, nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var opts model.QueryOptions - if itemType == "MusicAlbum" { - opts = ds.Album().(*tests.MockAlbumRepo).Options - } else { - opts = ds.MediaFile().(*tests.MockMediaFileRepo).Options - } - if opts.Filters == nil { - return - } - sql, _, err := opts.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).NotTo(ContainSubstring("starred")) - Expect(sql).NotTo(ContainSubstring("play_count")) - }, - Entry("albums, IsFavorite", "MusicAlbum", "IsFavorite"), - Entry("albums, IsUnplayed", "MusicAlbum", "IsUnplayed"), - Entry("albums, IsPlayed", "MusicAlbum", "IsPlayed"), - Entry("songs, IsFavorite", "Audio", "IsFavorite"), - Entry("songs, IsUnplayed", "Audio", "IsUnplayed"), - ) - - It("forwards SearchTerm to the repo's Search method", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - }) - - It("caps a search the client left unbounded", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) - }) - - It("honors an explicit search Limit up to the ceiling", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Max).To(Equal(501)) - }) - - It("clamps a search Limit that would materialize the library", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) - }) - - It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - Expect(albumRepo.SearchQuery).To(BeEmpty()) - }) - - It("reports a multi-type search total past the page, so clients keep paging", func() { - songs := make(model.MediaFiles, defaultSearchLimit*2) - for i := range songs { - songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} - } - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(10)) - Expect(res.TotalRecordCount).To(BeNumerically(">", 10)) - }) - - It("bounds the multi-type search window however large StartIndex is", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - // Without the bound this asks each type for ~500001 rows. - Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) - }) - - It("stops a multi-type search at the ceiling rather than serving another type's rows", func() { - // Bounding the per-type window is what keeps StartIndex from driving it without limit, and - // past that window the merged order is no longer the true one. - songs := make(model.MediaFiles, maxSearchLimit+1) - for i := range songs { - songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} - } - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", - fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit), - nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(BeEmpty()) - Expect(res.TotalRecordCount).To(Equal(maxSearchLimit)) - }) - - It("serves the last page below the ceiling in full", func() { - songs := make(model.MediaFiles, maxSearchLimit+1) - for i := range songs { - songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} - } - // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. - slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", - fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1), - nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - // Clipped to the window; the interleaved album takes one slot, shifting this song in by one. - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-2].ID))) - }) - - It("bounds an unbounded multi-type search to the default in total, not per type", func() { - songs := make(model.MediaFiles, defaultSearchLimit*2) - for i := range songs { - songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} - } - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(defaultSearchLimit)) - }) - - It("pages an unbounded multi-type search past the default without dropping matches", func() { - songs := make(model.MediaFiles, defaultSearchLimit*2) - for i := range songs { - songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} - } - // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. - slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", - fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50), - nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).ToNot(BeEmpty()) - // The interleaved album takes one slot ahead of it, shifting this song in by one. - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+49].ID))) - }) - - It("reports a search total beyond the fetched page instead of the page length", func() { - ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{ - {ID: testID("r1"), Name: "Alpha"}, {ID: testID("r2"), Name: "Beta"}, {ID: testID("r3"), Name: "Gamma"}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.TotalRecordCount).To(Equal(3)) - }) - - It("forwards StartIndex/Limit as Offset/Max", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Offset).To(Equal(5)) - Expect(albumRepo.Options.Max).To(Equal(10)) - }) - - Describe("Ids batch-fetch", func() { - // Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=; without - // this, queryItems ignored Ids and returned the default type-dispatched list instead. - It("returns exactly the requested item when Ids has a single id", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - Expect(res.Items[0].Name).To(Equal("Song")) - Expect(res.TotalRecordCount).To(Equal(1)) - }) - - It("returns items of different types for a lowercase ids param with multiple ids", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - ids := []string{res.Items[0].Id, res.Items[1].Id} - Expect(ids).To(ConsistOf(dto.EncodeID(testID("a1")), dto.EncodeID(testID("s1")))) - types := []string{res.Items[0].Type, res.Items[1].Type} - Expect(types).To(ConsistOf("MusicAlbum", "Audio")) - Expect(res.TotalRecordCount).To(Equal(2)) - }) - - It("resolves song ids with one batched IN query, not a Get per id", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}, {ID: testID("s2"), Title: "Song2", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - sql, args, err := mfRepo.Options.Filters.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("media_file.id IN")) - Expect(args).To(ConsistOf(testID("s1"), testID("s2"))) - }) - - It("omits an id in a library the user can't access, without erroring the whole batch", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) // alice only has access to library 1 - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("a1")))) - Expect(res.TotalRecordCount).To(Equal(1)) - }) - }) - - Describe("sorting", func() { - DescribeTable("translates SortBy into the repo's sort keys", - func(itemType, sortBy, want string) { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes="+itemType+"&SortBy="+sortBy, nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - got := mfRepo.Options.Sort - if itemType == "MusicAlbum" { - got = albumRepo.Options.Sort - } - Expect(got).To(Equal(want)) - }, - Entry("PlayCount", "MusicAlbum", "PlayCount", "play_count"), - Entry("DatePlayed", "Audio", "DatePlayed", "play_date"), - Entry("Runtime on albums", "MusicAlbum", "Runtime", "duration"), - Entry("RunTimeTicks alias", "MusicAlbum", "RunTimeTicks", "duration"), - // Finamp leads its track sort with Runtime: unless that resolves, the first recognized - // key is AlbumArtist and the list looks sorted while being sorted by the wrong thing. - Entry("Finamp's Runtime-led track sort", "Audio", "Runtime,AlbumArtist,Album,SortName", - "duration, album_artist, album, title"), - Entry("every recognized key, in order", "MusicAlbum", "DateCreated,SortName", "recently_added, name"), - Entry("a key repeating a column is dropped", "Audio", - "PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName", "year, album, title"), - // random is matched by exact string equality in the repo, so it can never share a sort. - Entry("Random stays alone", "MusicAlbum", "Random,SortName", "random"), - Entry("unrecognized keys are skipped", "Audio", "Runtime,Nonsense,SortName", "duration, title"), - Entry("only the last key recognized", "Audio", "Unknown1,Unknown2,SortName", "title"), - Entry("Finamp's album view is disc+track", "Audio", "ParentIndexNumber,IndexNumber,SortName", "album, title"), - Entry("nothing recognized leaves the repo default", "MusicAlbum", "SeriesSortName", ""), - ) - - // Jellyfin allows a per-key SortOrder list; we cannot express that through one Order, so - // we honor the first value for all keys, matching Jellyfin's fallback for extra keys. - DescribeTable("reads the first SortOrder value for the whole sort", - func(sortOrder, want string) { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", - "/Items?IncludeItemTypes=MusicAlbum&SortBy=Runtime,SortName&SortOrder="+sortOrder, nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Order).To(Equal(want)) - }, - Entry("ascending", "Ascending", ""), - Entry("descending", "Descending", "desc"), - Entry("descending leading a list", "Descending,Ascending", "desc"), - Entry("ascending leading a list", "Ascending,Descending", ""), - ) - }) - - Describe("library scoping", func() { - It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := mfRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("scopes a MusicArtist listing to the user's accessible libraries", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_artist.library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(2)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).NotTo(ContainSubstring("album_artists")) // not treated as an artist-parent filter - Expect(sql).To(ContainSubstring("library_id")) - Expect(args).To(ContainElement(2)) - }) - - It("does not let ParentId= scope results to that library", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}} // no access to library 99 - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(99)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - // Falls back to treating "99" as an (empty-matching) artist-parent id... - Expect(sql).To(ContainSubstring("album_artists")) - // ...while still scoping to the user's own accessible libraries. - Expect(sql).To(ContainSubstring("library_id")) - Expect(args).To(ContainElement(1)) - Expect(args).NotTo(ContainElement(99)) - }) - - It("does not restrict a default MusicAlbum listing for an admin user", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}, {ID: testID("a2"), Name: "Two", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - // accessibleLibraryIDs is empty for an admin (Libraries is nil), so - // ApplyLibraryFilter([]) is a no-op: no library_id restriction is added. - if albumRepo.Options.Filters == nil { - return - } - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).NotTo(ContainSubstring("library_id")) - }) - }) - - // A malformed id must 404, not silently drop the filter; a well-formed but unknown one must - // still reach the entity filter, not the unfiltered default. - Describe("stale and malformed id filtering", func() { - It("404s a malformed ParentId instead of listing every song", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s a malformed AlbumArtistIds instead of listing every album", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s a malformed ArtistIds instead of listing every song", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("still applies the artist filter (rather than dropping it) for a well-formed but unknown AlbumArtistIds", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds="+dto.EncodeID(testID("no-such-artist")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("album_artists")) - }) - - It("still applies the album filter (rather than dropping it) for a well-formed but unknown ParentId", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId="+dto.EncodeID(testID("no-such-album")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := mfRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("album_id")) - Expect(args).To(ContainElement(testID("no-such-album"))) - }) - }) - - Describe("mixed IncludeItemTypes merge", func() { - BeforeEach(func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "S1"}, {ID: testID("s2"), Title: "S2"}}) - }) - - It("returns a mix of both types, not all of one", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(4)) - Expect(res.TotalRecordCount).To(Equal(4)) - types := map[string]int{} - for _, it := range res.Items { - types[it.Type]++ - } - Expect(types["Audio"]).To(Equal(2)) - Expect(types["MusicAlbum"]).To(Equal(2)) - }) - - It("interleaves types round-robin (Audio first, per IncludeItemTypes order)", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - got := []string{res.Items[0].Type, res.Items[1].Type, res.Items[2].Type, res.Items[3].Type} - Expect(got).To(Equal([]string{"Audio", "MusicAlbum", "Audio", "MusicAlbum"})) - }) - - It("honors Limit across the merged set", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=1", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.TotalRecordCount).To(Equal(4)) - }) - - It("serves a full random page from offset 0 regardless of StartIndex", func() { - // A deep StartIndex on a random merge must not materialize offset+limit rows; since random - // reshuffles per request, offset 0 is an equivalent fresh draw. Old behavior returned empty. - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SortBy=Random&Recursive=true&StartIndex=1000&Limit=4", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(4)) - }) - - It("propagates a per-type query error", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetError(true) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - }) - - Describe("getItem", func() { - It("returns an album by id", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID(testID("a1")))) - Expect(item.Type).To(Equal("MusicAlbum")) - }) - - It("returns 404 when the id doesn't match any entity", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 for an album in a library the user can't access", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 for a song in a library the user can't access", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns an album to an admin even when it's outside their (empty) Libraries", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxAdmin()) // admin, Libraries: nil - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID(testID("a1")))) - }) - - // Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the - // library node before it can load the home screen or any library tab. - It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() { - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1, Name: "Music Library"}} - ds.Library().(*tests.MockLibraryRepo).SetData(libs) - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) - r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeLibraryID(1))) - Expect(item.Name).To(Equal("Music Library")) - Expect(item.Type).To(Equal("CollectionFolder")) - Expect(item.CollectionType).To(Equal("music")) - Expect(item.IsFolder).To(BeTrue()) - }) - - It("does not resolve a library-view id the user has no access to", func() { - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1 - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) - r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - // Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it - // 404s with "Could not fetch BaseItemDto from server." - It("resolves a playlist id via the playlists service", func() { - fp.getByIDPls = &model.Playlist{ID: testID("p1"), Name: "My Mix", SongCount: 5} - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID(testID("p1")))) - Expect(item.Name).To(Equal("My Mix")) - Expect(item.Type).To(Equal("Playlist")) - }) - - It("returns 404 for a non-owned or absent playlist id", func() { - fp.getByIDErr = model.ErrNotFound - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - // Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID). - It("resolves a genre id as a MusicGenre item", func() { - Expect(ds.Genre().(*tests.MockedGenreRepo).Put(&model.Genre{ID: testID("g1"), Name: "Rock"})).To(Succeed()) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID(testID("g1")))) - Expect(item.Name).To(Equal("Rock")) - Expect(item.Type).To(Equal("MusicGenre")) - }) - - It("resolves a library-view id for an admin even though their Libraries slice is empty", func() { - ds.Library().(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxAdmin()) - r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeLibraryID(1))) - Expect(item.Name).To(Equal("Music Library")) - Expect(item.Type).To(Equal("CollectionFolder")) - }) - }) - - Describe("getLatest", func() { - It("returns a bare array of the newest albums", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser()) - invoke(api.getLatest, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var items []dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed()) - Expect(items).To(HaveLen(1)) - Expect(items[0].Id).To(Equal(dto.EncodeID(testID("a1")))) - }) - - It("scopes to the user's accessible libraries", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getLatest, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - }) - - Describe("applySort random for all merge types", func() { - DescribeTable("maps Random -> random", - func(itemType string) { - var opts model.QueryOptions - applySort(&opts, itemType, "Random", "") - Expect(opts.Sort).To(Equal("random")) - }, - Entry("Audio", "Audio"), - Entry("MusicAlbum", "MusicAlbum"), - Entry("MusicArtist", "MusicArtist"), - Entry("MusicGenre", "MusicGenre"), - Entry("Playlist", "Playlist"), - ) - }) - - Describe("interleave", func() { - It("round-robins one item per list in turn", func() { - lists := [][]dto.BaseItemDto{ - {{Id: "a0"}, {Id: "a1"}, {Id: "a2"}}, - {{Id: "b0"}, {Id: "b1"}}, - } - got := interleave(lists) - ids := make([]string, len(got)) - for i, it := range got { - ids[i] = it.Id - } - Expect(ids).To(Equal([]string{"a0", "b0", "a1", "b1", "a2"})) - }) - - It("returns empty for no lists", func() { - Expect(interleave(nil)).To(BeEmpty()) - }) - }) - - Describe("parseTypes", func() { - It("dedupes repeated types, preserving first-seen order", func() { - Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"})) - }) - - It("matches type names case-insensitively, like Jellyfin's enum binding", func() { - Expect(parseTypes("musicalbum, AUDIO")).To(Equal([]string{"MusicAlbum", "Audio"})) - }) - - It("returns no types for real Jellyfin kinds Navidrome has none of", func() { - Expect(parseTypes("Boxset")).To(BeEmpty()) - Expect(parseTypes("BoxSet,Movie")).To(BeEmpty()) - }) - - It("drops names that aren't Jellyfin item kinds, case-insensitively", func() { - Expect(knownItemKinds("music")).To(BeEmpty()) - Expect(knownItemKinds("music, audio,MUSICVIDEO")).To(Equal("audio,MUSICVIDEO")) - }) - - It("defaults to albums only when IncludeItemTypes is absent", func() { - Expect(parseTypes("")).To(Equal([]string{"MusicAlbum"})) - Expect(parseTypes("Nonsense")).To(BeEmpty()) - }) - }) - - Describe("decodeFilterParam", func() { - It("reports ok for an absent param, decoding to \"\"", func() { - id, ok := decodeFilterParam("") - Expect(id).To(BeEmpty()) - Expect(ok).To(BeTrue()) - }) - - It("reports ok for a well-formed id, whether or not it exists", func() { - id, ok := decodeFilterParam(dto.EncodeID(testID("a1"))) - Expect(id).To(Equal(testID("a1"))) - Expect(ok).To(BeTrue()) - }) - - It("reports not ok for a non-empty param that fails to decode", func() { - id, ok := decodeFilterParam("not-a-valid-id") - Expect(id).To(BeEmpty()) - Expect(ok).To(BeFalse()) - }) - }) - - Describe("decodedQueryIDs", func() { - It("decodes every entry when all are well-formed", func() { - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("a2")), nil) - ids, ok := decodedQueryIDs(r, "ids") - Expect(ok).To(BeTrue()) - Expect(ids).To(Equal([]string{testID("a1"), testID("a2")})) - }) - - It("reports not ok and an empty list, not a partially-decoded one, for a mix of valid and malformed entries", func() { - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+",not-a-valid-id", nil) - ids, ok := decodedQueryIDs(r, "ids") - Expect(ok).To(BeFalse()) - Expect(ids).To(BeEmpty()) - }) - - It("reports ok for an absent param, decoding to an empty list", func() { - r := httptest.NewRequest("GET", "/Items", nil) - ids, ok := decodedQueryIDs(r, "ids") - Expect(ok).To(BeTrue()) - Expect(ids).To(BeEmpty()) - }) - }) -}) diff --git a/server/jellyfin/jellyfin_suite_test.go b/server/jellyfin/jellyfin_suite_test.go deleted file mode 100644 index 2ae7da6e2..000000000 --- a/server/jellyfin/jellyfin_suite_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package jellyfin - -import ( - "net/http" - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestJellyfinApi(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Jellyfin API Suite") -} - -// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same -// id shape production uses. -func testID(label string) string { return id.NewHash("jellyfin-test", label) } - -// invoke runs a handler through normalizeQueryKeys, mirroring the router. These unit tests call -// handlers directly (with withChiURLParam for path params) instead of routing, so without this the -// case-insensitive query folding real requests get would be skipped and PascalCase params dropped. -func invoke(h http.HandlerFunc, w http.ResponseWriter, r *http.Request) { - normalizeQueryKeys(h).ServeHTTP(w, r) -} diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go deleted file mode 100644 index 9f1f7ee73..000000000 --- a/server/jellyfin/library.go +++ /dev/null @@ -1,75 +0,0 @@ -package jellyfin - -import ( - "context" - "net/http" - "strconv" - - "github.com/Masterminds/squirrel" - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" -) - -// accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty -// slice (non-admin with no libraries) is treated as a no-op/unrestricted by the library filters. -func accessibleLibraryIDs(ctx context.Context) []int { - u, _ := request.UserFrom(ctx) - return u.Libraries.IDs() -} - -// resolveLibraryScope handles ParentId's ambiguity: a library id (browsing a UserView) or an -// entity id (artist/album). It's treated as a library only when the user has access; otherwise -// isLibraryParent is false and callers fall through to entity-id handling. -func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, isLibraryParent bool) { - if parentId != "" { - if id, err := strconv.Atoi(parentId); err == nil { - if u, _ := request.UserFrom(ctx); u.HasLibraryAccess(id) { - return []int{id}, true - } - } - } - return accessibleLibraryIDs(ctx), false -} - -// decodeFilterParam separates an absent param from an undecodable one: dropping the filter for a -// stale id would silently widen the query to the whole library. It is the only place that opts -// into treating "absent" as fine — every other caller of dto.DecodeID must 404 on ok=false. -func decodeFilterParam(raw string) (id string, ok bool) { - if raw == "" { - return "", true - } - return dto.DecodeID(raw) -} - -// itemIDParam decodes a chi URL id param, writing 404 and reporting false when it isn't a -// well-formed GUID. Handlers must return immediately when ok is false. -func itemIDParam(w http.ResponseWriter, r *http.Request, key string) (string, bool) { - id, ok := dto.DecodeID(chi.URLParam(r, key)) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return "", false - } - return id, true -} - -// parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope). -// ok is false when a non-empty ParentId fails to decode (see decodeFilterParam). -func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool, ok bool) { - parentId, ok := decodeFilterParam(req.Params(r).StringOr("parentid", "")) - if !ok { - return nil, false, false - } - scopeIDs, isLibraryParent = resolveLibraryScope(ctx, parentId) - return scopeIDs, isLibraryParent, true -} - -// libraryScopeFilter restricts a tag query to the given library scope. Empty scope means -// unrestricted (see accessibleLibraryIDs), so it returns nil rather than an impossible IN (). -func libraryScopeFilter(scope []int) squirrel.Sqlizer { - if len(scope) == 0 { - return nil - } - return squirrel.Eq{"library_tag.library_id": scope} -} diff --git a/server/jellyfin/lyrics.go b/server/jellyfin/lyrics.go deleted file mode 100644 index a9d77ba19..000000000 --- a/server/jellyfin/lyrics.go +++ /dev/null @@ -1,54 +0,0 @@ -package jellyfin - -import ( - "context" - "net/http" - "time" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -const lyricsLoadTimeout = time.Minute - -// cachedLyrics resolves lyrics through the full source pipeline (embedded, sidecar, plugins), -// caching results — including empty: clients poll per played track, so misses are the hot path. -func (api *Router) cachedLyrics(ctx context.Context, mf *model.MediaFile) model.LyricList { - // The load is shared across requests (singleflight) and cached, so don't let one - // cancelled request abort it for everybody — detach it from the request's lifetime, - // keeping a bound so a hung plugin can't pin the fetch (and its plugin slot) forever. - loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), lyricsLoadTimeout) - defer cancel() - list, err := api.lyricsCache.GetWithLoader(mf.ID, func(string) (model.LyricList, time.Duration, error) { - l, err := api.lyrics.GetLyrics(loadCtx, mf) - return l, 0, err // 0 → cache DefaultTTL - }) - if err != nil { - log.Error(ctx, "Error getting lyrics", "id", mf.ID, "title", mf.Title, err) - return nil - } - return list -} - -// getLyrics serves GET /Audio/{itemId}/Lyrics. Jellyfin returns 404 when a track has no lyrics -// (never an empty 200); all surveyed clients treat that gracefully. -func (api *Router) getLyrics(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - main, found := servableLyric(api.cachedLyrics(r.Context(), mf)) - if !found { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - api.ok(w, r, dto.LyricDtoFromLyrics(*mf, main)) -} - -// servableLyric is the single predicate for both serving and advertising, so PlaybackInfo never -// advertises a Lyric stream that this endpoint would 404. -func servableLyric(list model.LyricList) (model.Lyrics, bool) { - main, found := list.Main() - return main, found && !main.IsEmpty() -} diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go deleted file mode 100644 index fb00563de..000000000 --- a/server/jellyfin/lyrics_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// fakeLyricsService returns canned lyrics per media-file ID and counts calls. -type fakeLyricsService struct { - lyrics map[string]model.LyricList - err error - calls int - hadDeadline bool -} - -func (f *fakeLyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { - f.calls++ - _, f.hadDeadline = ctx.Deadline() - if err := ctx.Err(); err != nil { - return nil, err - } - if f.err != nil { - return nil, f.err - } - return f.lyrics[mf.ID], nil -} - -func (f *fakeLyricsService) GetLyricsByArtistTitle(context.Context, string, string) (model.LyricList, error) { - return nil, nil -} - -func p(ms int64) *int64 { return &ms } - -func newTestLyricsCache() cache.SimpleCache[string, model.LyricList] { - return cache.NewSimpleCache[string, model.LyricList](cache.Options{SizeLimit: 1000}) -} - -var _ = Describe("getLyrics", func() { - var api *Router - var ds *tests.MockDataStore - var fake *fakeLyricsService - - BeforeEach(func() { - ds = &tests.MockDataStore{} - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", LibraryID: 1}, - {ID: testID("s2"), Title: "Silent Song", LibraryID: 1}, - }) - fake = &fakeLyricsService{lyrics: map[string]model.LyricList{}} - api = &Router{ - ds: ds, - lyrics: fake, - lyricsCache: newTestLyricsCache(), - } - }) - - doRequest := func(id string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}) - // Clients send hex-encoded ids (matching real traffic and the other handler tests). - enc := dto.EncodeID(id) - r := httptest.NewRequest("GET", "/Audio/"+enc+"/Lyrics", nil).WithContext(ctx) - r = withChiURLParam(r, "itemId", enc) - invoke(api.getLyrics, w, r) - return w - } - - It("returns 200 with a LyricDto for a track with synced lyrics", func() { - fake.lyrics[testID("s1")] = model.LyricList{ - {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, - } - w := doRequest(testID("s1")) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.LyricDto - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Lyrics).To(HaveLen(1)) - Expect(res.Lyrics[0].Text).To(Equal("hello")) - Expect(res.Lyrics[0].Start).ToNot(BeNil()) - Expect(*res.Lyrics[0].Start).To(Equal(int64(10000000))) - }) - - It("serves the main-kind lyric when a translation is also present", func() { - fake.lyrics[testID("s1")] = model.LyricList{ - {Kind: "translation", Synced: true, Line: []model.Line{{Start: p(1000), Value: "bonjour"}}}, - {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, - } - w := doRequest(testID("s1")) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.LyricDto - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Lyrics).To(HaveLen(1)) - Expect(res.Lyrics[0].Text).To(Equal("hello")) - }) - - It("returns 404 when the service returns no lyrics", func() { - w := doRequest(testID("s2")) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 when the main lyric has no lines", func() { - fake.lyrics[testID("s1")] = model.LyricList{{Kind: "main", Lang: "eng"}} - w := doRequest(testID("s1")) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 for an unknown item id", func() { - w := doRequest("unknown") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("caches results so a second request doesn't re-invoke the service", func() { - fake.lyrics[testID("s1")] = model.LyricList{ - {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, - } - Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) - Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) - Expect(fake.calls).To(Equal(1)) - }) - - It("caches empty results too", func() { - Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) - Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) - Expect(fake.calls).To(Equal(1)) - }) - - It("completes and caches the fetch even when the request context is cancelled", func() { - fake.lyrics[testID("s1")] = model.LyricList{ - {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - list := api.cachedLyrics(ctx, &model.MediaFile{ID: testID("s1")}) - Expect(list).ToNot(BeEmpty()) - Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) - Expect(fake.calls).To(Equal(1)) - }) - - It("bounds the detached fetch with a timeout", func() { - Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) - Expect(fake.hadDeadline).To(BeTrue()) - }) -}) diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go deleted file mode 100644 index 4788c84be..000000000 --- a/server/jellyfin/middlewares.go +++ /dev/null @@ -1,224 +0,0 @@ -package jellyfin - -import ( - "net" - "net/http" - "net/url" - "regexp" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" -) - -// throttleStreams bounds how many collection responses stream concurrently, so they can't take every -// connection in the shared DB pool: each holds a cursor, and its connection, for the whole -// client-paced response. Excess requests queue rather than fail. limit <= 0 disables it. -// -// Deliberately chi's ThrottleBacklog and not server.ThrottleBacklog: the latter buffers the entire -// response to release its token early, which is right for artwork but would undo the streaming here. -// chi's panics on a non-positive limit, hence the guard. -func throttleStreams(limit int) func(http.Handler) http.Handler { - if limit <= 0 { - return func(next http.Handler) http.Handler { return next } - } - return middleware.ThrottleBacklog(limit, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout) -} - -// caseInsensitivePaths lowercases the request path so chi (case-sensitive) matches the -// lowercase-registered routes; Jellyfin clients route case-insensitively. It lowercases id/param -// segments too, which is safe because every id the API emits — user ids included — is lowercase hex -// (dto.EncodeID). -func caseInsensitivePaths(r chi.Router) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - // Mounted under a parent, chi matches RouteContext.RoutePath, not r.URL.Path. - if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" { - rctx.RoutePath = strings.ToLower(rctx.RoutePath) - } else { - req.URL.Path = strings.ToLower(req.URL.Path) - } - r.ServeHTTP(w, req) - }) -} - -// normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params -// case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, -// Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one -// client's filters, sort and paging. Only keys are folded — values keep their case. The original -// request is left untouched (a rewritten copy goes downstream) so logging shows the client's casing. -func normalizeQueryKeys(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - folded := make(url.Values, len(q)) - changed := false - for k, vs := range q { - lk := strings.ToLower(k) - // Append, don't assign: two casings of the same key must merge, not overwrite. - folded[lk] = append(folded[lk], vs...) - if lk != k { - changed = true - } - } - if changed { - r2 := *r - u := *r.URL - u.RawQuery = folded.Encode() - r2.URL = &u - r = &r2 - } - next.ServeHTTP(w, r) - }) -} - -type mediaBrowserAuth struct { - Client, Device, DeviceId, Version, Token string -} - -var mediaBrowserAuthField = regexp.MustCompile(`(\w+)="([^"]*)"`) - -// parseMediaBrowserAuth reads the MediaBrowser-scheme authorization header, e.g. -// `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="abc", Version="1.0", Token="jwt"`. -// The recommended Authorization header is preferred, but only when it actually carries -// MediaBrowser data — a reverse proxy may inject Basic/Digest credentials there while the client -// sends the deprecated X-Emby-Authorization. Field values are URL-decoded: Jellify (@jellyfin/sdk) -// percent-encodes them (Device="Pixel%208%20Pro"), while Finamp sends them raw; unescapeField -// leaves a raw value untouched. -func parseMediaBrowserAuth(r *http.Request) mediaBrowserAuth { - if a, ok := parseAuthHeader(r.Header.Get("Authorization")); ok { - return a - } - a, _ := parseAuthHeader(r.Header.Get("X-Emby-Authorization")) - return a -} - -// parseAuthHeader extracts the MediaBrowser fields from one header value; ok reports whether the -// value uses the MediaBrowser scheme ("Emby" is the legacy spelling real Jellyfin also accepts). -func parseAuthHeader(h string) (mediaBrowserAuth, bool) { - var a mediaBrowserAuth - scheme, params, found := strings.Cut(h, " ") - if !found || (!strings.EqualFold(scheme, "MediaBrowser") && !strings.EqualFold(scheme, "Emby")) { - return a, false - } - for _, m := range mediaBrowserAuthField.FindAllStringSubmatch(params, -1) { - switch m[1] { - case "Client": - a.Client = unescapeField(m[2]) - case "Device": - a.Device = unescapeField(m[2]) - case "DeviceId": - a.DeviceId = unescapeField(m[2]) - case "Version": - a.Version = unescapeField(m[2]) - case "Token": - a.Token = unescapeField(m[2]) - } - } - return a, true -} - -// unescapeField percent-decodes a header field value, falling back to the raw value when it isn't -// valid encoding (Finamp sends raw values that may contain a literal '%'). PathUnescape, not -// QueryUnescape, so a literal '+' in a value is preserved rather than turned into a space. -func unescapeField(v string) string { - if decoded, err := url.PathUnescape(v); err == nil { - return decoded - } - return v -} - -// tokenFromRequest prefers the recommended Authorization scheme; the rest are legacy spellings -// deprecated by Jellyfin but still sent by clients. -func tokenFromRequest(r *http.Request) string { - if t := parseMediaBrowserAuth(r).Token; t != "" { - return t - } - if t := r.Header.Get("X-Emby-Token"); t != "" { - return t - } - if t := r.Header.Get("X-MediaBrowser-Token"); t != "" { - return t - } - // api_key and apikey differ by an underscore, not case, so normalizeQueryKeys' folding doesn't - // merge them; both are checked (Finamp's just_audio engine fetches direct-file URLs with ?ApiKey=). - if t := r.URL.Query().Get("api_key"); t != "" { - return t - } - return r.URL.Query().Get("apikey") -} - -// userFromToken resolves the user for the request's token; ok is false for a missing/invalid token -// or unknown subject. -func (api *Router) userFromToken(r *http.Request) (model.User, bool) { - token := tokenFromRequest(r) - if token == "" { - return model.User{}, false - } - claims, err := auth.Validate(token) - if err != nil || claims.Subject == "" { - return model.User{}, false - } - usr, err := api.ds.User().FindByUsername(r.Context(), claims.Subject) - if err != nil { - log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err) - return model.User{}, false - } - if err := auth.CheckClaims(claims, *usr, auth.AudienceJellyfin); err != nil { - log.Warn(r.Context(), "Jellyfin API: rejected token", "user", claims.Subject, err) - return model.User{}, false - } - return *usr, true -} - -func (api *Router) authenticate(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - usr, ok := api.userFromToken(r) - if !ok { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - ctx := request.WithUser(r.Context(), usr) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -// withPlayer resolves/registers a model.Player for the calling device into the context, mirroring -// Subsonic's getPlayer. Jellyfin clients always send a DeviceId in the auth header (unlike Subsonic), -// so it's used directly as the player id and reports from the same install share a player/scrobbling -// session. -func (api *Router) withPlayer(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if api.players == nil { // fail open when players isn't wired (e.g. in unit tests) - next.ServeHTTP(w, r) - return - } - ctx := r.Context() - a := parseMediaBrowserAuth(r) - // Skip registration when the request can't identify a client (no X-Emby-Authorization, e.g. - // the /socket handshake that authenticates via ?api_key= only). Otherwise Register would - // create a junk player with an empty name (" []"). - if a.Client == "" && a.DeviceId == "" { - next.ServeHTTP(w, r) - return - } - ip, _, _ := net.SplitHostPort(r.RemoteAddr) - player, trc, err := api.players.Register(ctx, a.DeviceId, a.Client, a.Device, ip) - if err != nil { - // Fail open, like Subsonic's getPlayer: proceed without a player; reporting handlers - // degrade gracefully. - log.Warn(ctx, "Jellyfin API: could not register player", "client", a.Client, "device", a.Device, err) - next.ServeHTTP(w, r) - return - } - ctx = request.WithPlayer(ctx, *player) - // Like Subsonic's getPlayer: the forced transcoding must reach ResolveRequest's override. - if trc != nil { - ctx = request.WithTranscoding(ctx, *trc) - } - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go deleted file mode 100644 index 2ab9f35eb..000000000 --- a/server/jellyfin/middlewares_test.go +++ /dev/null @@ -1,430 +0,0 @@ -package jellyfin - -import ( - "context" - "net/http" - "net/http/httptest" - "sync" - "sync/atomic" - "time" - - "github.com/go-chi/chi/v5" - "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("authenticate middleware", func() { - var ctx context.Context - var api *Router - var ds *tests.MockDataStore - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) - api = &Router{ds: ds} - }) - - tokenFor := func(name string) string { - t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: name}) - Expect(err).ToNot(HaveOccurred()) - return t - } - - It("passes with a valid X-Emby-Token and injects the user", func() { - var gotUser model.User - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUser, _ = request.UserFrom(r.Context()) - w.WriteHeader(http.StatusOK) - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("X-Emby-Token", tokenFor("alice")) - api.authenticate(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotUser.UserName).To(Equal("alice")) - }) - - It("passes with the recommended Authorization: MediaBrowser scheme and injects the user", func() { - var gotUser model.User - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUser, _ = request.UserFrom(r.Context()) - w.WriteHeader(http.StatusOK) - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("Authorization", `MediaBrowser Token="`+tokenFor("alice")+`", Client="Test", DeviceId="dev1"`) - api.authenticate(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotUser.UserName).To(Equal("alice")) - }) - - It("rejects a missing token with 401", func() { - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - api.authenticate(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects a garbage token with 401 and does not call next", func() { - nextCalled := false - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - nextCalled = true - w.WriteHeader(http.StatusOK) - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("X-Emby-Token", "not-a-jwt") - api.authenticate(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(nextCalled).To(BeFalse()) - }) - - It("rejects a valid token whose subject user does not exist with 401", func() { - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - t, err := auth.CreateToken(&model.User{ID: "x", UserName: "ghost"}) - Expect(err).ToNot(HaveOccurred()) - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("X-Emby-Token", t) - api.authenticate(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - Context("token scoping and revocation", func() { - var usr *model.User - - BeforeEach(func() { - ur := ds.User().(*tests.MockedUserRepo) - usr = &model.User{ID: testID("u2"), UserName: "bob", NewPassword: "secret", TokenEpoch: 3} - Expect(ur.Put(ctx, usr)).To(Succeed()) - }) - - serve := func(token string) *httptest.ResponseRecorder { - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("X-Emby-Token", token) - api.authenticate(next).ServeHTTP(w, r) - return w - } - - It("accepts a jellyfin-scoped token with the current epoch", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) - }) - - It("rejects a token whose epoch is stale", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - usr.TokenEpoch = 4 - Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects a token minted for another API", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceNative) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) - }) - - It("still accepts an unscoped session token", func() { - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) - }) - }) -}) - -var _ = Describe("withPlayer middleware", func() { - var api *Router - var players *fakePlayers - - BeforeEach(func() { - players = &fakePlayers{} - api = &Router{ds: &tests.MockDataStore{}, players: players} - }) - - callWith := func() (model.Player, model.Transcoding, bool) { - var gotPlayer model.Player - var gotTrc model.Transcoding - var hasTrc bool - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPlayer, _ = request.PlayerFrom(r.Context()) - gotTrc, hasTrc = request.TranscodingFrom(r.Context()) - w.WriteHeader(http.StatusOK) - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream", nil) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) - api.withPlayer(next).ServeHTTP(w, r) - return gotPlayer, gotTrc, hasTrc - } - - It("injects the registered player into the context", func() { - player, _, hasTrc := callWith() - Expect(player.ID).To(Equal("dev1")) - Expect(hasTrc).To(BeFalse()) - }) - - It("injects the player's server-forced transcoding into the context", func() { - players.trc = &model.Transcoding{ID: testID("t1"), TargetFormat: "opus"} - _, trc, hasTrc := callWith() - Expect(hasTrc).To(BeTrue()) - Expect(trc.TargetFormat).To(Equal("opus")) - }) -}) - -var _ = Describe("tokenFromRequest", func() { - It("accepts the recommended Authorization: MediaBrowser scheme", func() { - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("Authorization", `MediaBrowser Token="tok123", Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) - Expect(tokenFromRequest(r)).To(Equal("tok123")) - }) - - It("prefers the Authorization scheme token over deprecated token headers", func() { - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("Authorization", `MediaBrowser Token="scheme-token"`) - r.Header.Set("X-Emby-Token", "legacy-token") - Expect(tokenFromRequest(r)).To(Equal("scheme-token")) - }) - - It("accepts the lowercase api_key query param", func() { - r := httptest.NewRequest("GET", "/Items/s1/File?api_key=tok123", nil) - Expect(tokenFromRequest(r)).To(Equal("tok123")) - }) - - It("accepts a PascalCase ApiKey query param once normalizeQueryKeys has folded it", func() { - r := httptest.NewRequest("GET", "/Items/s1/File?ApiKey=tok123", nil) - var got string - invoke(func(_ http.ResponseWriter, r *http.Request) { got = tokenFromRequest(r) }, httptest.NewRecorder(), r) - Expect(got).To(Equal("tok123")) - }) -}) - -var _ = Describe("parseMediaBrowserAuth", func() { - authFor := func(header string) mediaBrowserAuth { - r := httptest.NewRequest("GET", "/", nil) - r.Header.Set("X-Emby-Authorization", header) - return parseMediaBrowserAuth(r) - } - - It("reads Finamp's raw (unencoded) field values", func() { - a := authFor(`MediaBrowser Client="Finamp", Device="Pixel 8 Pro", DeviceId="dev1", Version="1.0", Token="tok"`) - Expect(a.Client).To(Equal("Finamp")) - Expect(a.Device).To(Equal("Pixel 8 Pro")) - Expect(a.DeviceId).To(Equal("dev1")) - }) - - It("percent-decodes Jellify's URL-encoded field values", func() { - a := authFor(`MediaBrowser Client="Jellify", Device="Pixel%208%20Pro", DeviceId="dev1", Version="1.0", Token="tok"`) - Expect(a.Client).To(Equal("Jellify")) - Expect(a.Device).To(Equal("Pixel 8 Pro")) - }) - - It("keeps a literal '%' that isn't valid percent-encoding", func() { - a := authFor(`MediaBrowser Client="100% Player", Device="d"`) - Expect(a.Client).To(Equal("100% Player")) - }) - - It("prefers the recommended Authorization header over the deprecated X-Emby-Authorization", func() { - r := httptest.NewRequest("GET", "/", nil) - r.Header.Set("Authorization", `MediaBrowser Client="New", DeviceId="dev-new"`) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Old", DeviceId="dev-old"`) - a := parseMediaBrowserAuth(r) - Expect(a.Client).To(Equal("New")) - Expect(a.DeviceId).To(Equal("dev-new")) - }) - - It("falls back to X-Emby-Authorization when Authorization carries a foreign scheme", func() { - // A reverse proxy may inject Basic/Digest credentials; the client's MediaBrowser data must - // still be honored. - r := httptest.NewRequest("GET", "/", nil) - r.Header.Set("Authorization", `Digest username="proxy", realm="site"`) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", DeviceId="dev1", Token="tok"`) - a := parseMediaBrowserAuth(r) - Expect(a.Client).To(Equal("Finamp")) - Expect(a.Token).To(Equal("tok")) - }) - - It("rejects a foreign scheme even when its parameters mimic MediaBrowser fields", func() { - r := httptest.NewRequest("GET", "/", nil) - r.Header.Set("Authorization", `Custom Token="not-for-us"`) - Expect(parseMediaBrowserAuth(r).Token).To(BeEmpty()) - }) - - It("accepts the legacy Emby scheme spelling, like real Jellyfin", func() { - a := authFor(`Emby Client="OldClient", DeviceId="dev1", Token="tok"`) - Expect(a.Client).To(Equal("OldClient")) - Expect(a.Token).To(Equal("tok")) - }) - - It("matches the scheme case-insensitively (HTTP auth schemes are)", func() { - a := authFor(`mediabrowser Token="tok"`) - Expect(a.Token).To(Equal("tok")) - }) -}) - -var _ = Describe("normalizeQueryKeys", func() { - // keyFor runs a request through normalizeQueryKeys and reports the value the handler sees for - // the given (lowercase) key — i.e. what a case-insensitive read would find. - keyFor := func(rawQuery, key string) string { - r := httptest.NewRequest("GET", "/Items?"+rawQuery, nil) - var got string - invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query().Get(key) }, httptest.NewRecorder(), r) - return got - } - - It("folds PascalCase (Finamp) and camelCase (Jellify) keys to lowercase", func() { - Expect(keyFor("ParentId=abc", "parentid")).To(Equal("abc")) - Expect(keyFor("parentId=abc", "parentid")).To(Equal("abc")) - }) - - It("leaves values untouched", func() { - Expect(keyFor("IncludeItemTypes=MusicAlbum,Audio", "includeitemtypes")).To(Equal("MusicAlbum,Audio")) - }) - - It("passes already-lowercase keys through unchanged", func() { - Expect(keyFor("container=mp3", "container")).To(Equal("mp3")) - }) - - It("merges values when two keys fold to the same name instead of dropping one", func() { - r := httptest.NewRequest("GET", "/Items?Ids=aaa&ids=bbb", nil) - var got []string - invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query()["ids"] }, httptest.NewRecorder(), r) - Expect(got).To(ConsistOf("aaa", "bbb")) - }) -}) - -var _ = Describe("throttleStreams", func() { - // serve fires n concurrent requests through the middleware and reports the highest number that - // were ever inside the handler at once. - serve := func(limit, n int) int32 { - var inFlight, peak int32 - release := make(chan struct{}) - h := throttleStreams(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cur := atomic.AddInt32(&inFlight, 1) - for { - old := atomic.LoadInt32(&peak) - if cur <= old || atomic.CompareAndSwapInt32(&peak, old, cur) { - break - } - } - <-release // hold the slot until every request has had a chance to enter - atomic.AddInt32(&inFlight, -1) - })) - - var wg sync.WaitGroup - for range n { - wg.Add(1) - go func() { - defer wg.Done() - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) - }() - } - // Give the admitted requests time to pile up before letting them finish. - time.Sleep(100 * time.Millisecond) - close(release) - wg.Wait() - return atomic.LoadInt32(&peak) - } - - It("admits no more than the limit at once", func() { - Expect(serve(2, 8)).To(Equal(int32(2))) - }) - - It("queues the excess rather than rejecting it", func() { - // All 8 still complete — they wait for a slot instead of getting a 429. - var served int32 - release := make(chan struct{}) - h := throttleStreams(2)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - <-release - atomic.AddInt32(&served, 1) - })) - var wg sync.WaitGroup - for range 8 { - wg.Add(1) - go func() { - defer wg.Done() - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) - }() - } - close(release) - wg.Wait() - Expect(served).To(Equal(int32(8))) - }) - - // chi's ThrottleBacklog panics on a non-positive limit, so a user disabling the cap must not - // crash the server at startup. - It("is disabled, not panicking, when the limit is zero", func() { - Expect(func() { serve(0, 4) }).ToNot(Panic()) - Expect(serve(0, 4)).To(BeNumerically(">", int32(1))) - }) -}) - -var _ = Describe("caseInsensitivePaths", func() { - var handler http.Handler - var gotID, gotContainer string - - BeforeEach(func() { - gotID, gotContainer = "", "" - r := chi.NewRouter() - // Routes are registered lowercase, mirroring the real router. - r.Get("/foo/{id}/bar", func(w http.ResponseWriter, req *http.Request) { - gotID = chi.URLParam(req, "id") - w.WriteHeader(http.StatusOK) - }) - r.Get("/audio/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { - gotContainer = chi.URLParam(req, "container") - w.WriteHeader(http.StatusOK) - }) - // A second route reusing the "bar" segment name at a different position. - r.Get("/bar/{id}", func(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(http.StatusOK) - }) - handler = caseInsensitivePaths(r) - }) - - serve := func(path string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) - return w - } - - It("routes a mixed-case request to its lowercase-registered route", func() { - Expect(serve("/FOO/abc/BAR").Code).To(Equal(http.StatusOK)) - }) - - It("routes both routes that share a segment name, regardless of casing", func() { - Expect(serve("/Foo/abc/Bar").Code).To(Equal(http.StatusOK)) - Expect(serve("/BAR/abc").Code).To(Equal(http.StatusOK)) - }) - - It("lowercases the mixed literal.extension segment so the route and container match", func() { - w := serve("/Audio/abc/STREAM.MP3") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotContainer).To(Equal("mp3")) - }) - - It("lowercases id/param segments (safe: Jellyfin ids are lowercase hex)", func() { - serve("/foo/DEADBEEF/bar") - Expect(gotID).To(Equal("deadbeef")) - }) - - It("normalizes the RoutePath branch when mounted under a parent", func() { - parent := chi.NewRouter() - parent.Mount("/jellyfin", handler) - w := httptest.NewRecorder() - parent.ServeHTTP(w, httptest.NewRequest("GET", "/jellyfin/FOO/abc/BAR", nil)) - Expect(w.Code).To(Equal(http.StatusOK)) - }) -}) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go deleted file mode 100644 index 5569f4f83..000000000 --- a/server/jellyfin/playlists.go +++ /dev/null @@ -1,403 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "math" - "net/http" - "strconv" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/filter" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" -) - -// playlistsFolder is the item returned for a ManualPlaylistsFolder query. CollectionType must be -// "playlists" — how the client identifies it; without it Jellify's playlist-library query loops. -func playlistsFolder() dto.BaseItemDto { - return dto.BaseItemDto{ - Id: dto.PlaylistsFolderGUID, - Name: "Playlists", - Type: "ManualPlaylistsFolder", - CollectionType: "playlists", - IsFolder: true, - } -} - -// playlistError maps core/playlists write errors to HTTP status: ownership or locked -> 403, -// missing/invisible -> 404 (never revealing another user's private playlist), else -> 500. -func (api *Router) playlistError(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable): - http.Error(w, "Forbidden", http.StatusForbidden) - case errors.Is(err, model.ErrNotFound): - http.Error(w, "Not Found", http.StatusNotFound) - default: - api.internalError(w, r, err) - } -} - -type createPlaylistRequest struct { - Name string `json:"Name"` - Ids []string `json:"Ids"` - MediaType string `json:"MediaType"` - IsPublic *bool `json:"IsPublic"` -} - -// createPlaylist always creates a new playlist (playlistId "" tells core/playlists.Create not to -// replace an existing one), owned by the authenticated user. -func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) { - var body createPlaylistRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - decoded, ok := dto.DecodeIDs(body.Ids) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - ids := api.expandContainerIDs(r.Context(), decoded) - id, err := api.playlists.Create(r.Context(), "", body.Name, ids) - if err != nil { - api.internalError(w, r, err) - return - } - // Create takes no visibility, so a requested one costs a second write. - if body.IsPublic != nil { - if err := api.playlists.Update(r.Context(), id, nil, nil, body.IsPublic, nil, nil); err != nil { - api.playlistError(w, r, err) - return - } - } - api.ok(w, r, map[string]string{"Id": dto.EncodeID(id)}) -} - -// updatePlaylistRequest mirrors Jellyfin's NewPlaylist body. Pointers so an absent field means -// "leave unchanged", distinguishing an omitted Ids (no change) from an explicit empty list (clear). -type updatePlaylistRequest struct { - Name *string `json:"Name"` - Ids *[]string `json:"Ids"` - IsPublic *bool `json:"IsPublic"` -} - -func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - var body updatePlaylistRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - - // A present Ids replaces the track list. An empty list must clear it explicitly, since Create - // can't persist an empty track list (the repository skips track writes when the list is empty). - if body.Ids != nil { - if len(*body.Ids) == 0 { - if err := api.clearPlaylist(ctx, id); err != nil { - api.playlistError(w, r, err) - return - } - } else { - decoded, ok := dto.DecodeIDs(*body.Ids) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - ids := api.expandContainerIDs(ctx, decoded) - if _, err := api.playlists.Create(ctx, id, "", ids); err != nil { - api.playlistError(w, r, err) - return - } - } - } - if body.Ids == nil || body.Name != nil || body.IsPublic != nil { - if err := api.playlists.Update(ctx, id, body.Name, nil, body.IsPublic, nil, nil); err != nil { - api.playlistError(w, r, err) - return - } - } - w.WriteHeader(http.StatusNoContent) -} - -// clearPlaylist removes every track from a playlist. RemoveTracks enforces ownership. -func (api *Router) clearPlaylist(ctx context.Context, id string) error { - pls, err := api.playlists.GetWithTracks(ctx, id) - if err != nil { - return err - } - if len(pls.Tracks) == 0 { - return nil - } - entryIDs := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.ID }) - return api.playlists.RemoveTracks(ctx, id, entryIDs) -} - -// playlistTrackPage streams one page of a playlist's tracks. Streams because a playlist can be the -// whole library (a smart playlist matching everything) and clients may omit Limit. Excludes missing -// tracks, and counts the same set, like GetWithTracks. -func (api *Router) playlistTrackPage(ctx context.Context, repo model.PlaylistTrackRepository, fields dto.Fields, offset, limit int) (itemsResult, error) { - total, err := repo.CountAll(ctx, model.QueryOptions{Filters: notMissing}) - if err != nil { - return itemsResult{}, err - } - opts := model.QueryOptions{Sort: "id", Offset: offset, Max: limit, Filters: notMissing} - open := streamCursor(func() (func(func(model.PlaylistTrack, error) bool), error) { - return repo.GetCursor(ctx, opts) - }, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) - return streamed(open, int(total), offset), nil -} - -// trackToBaseItem maps a playlist entry to a BaseItemDto, tagging it with PlaylistItemId (the -// entry's id, model.PlaylistTrack.ID, not the song id). Clients echo it back via -// DELETE .../Items?EntryIds= to remove a specific occurrence, so duplicates of the same song remain -// individually removable. -func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { - item := dto.SongToBaseItem(t.MediaFile, fields) - item.PlaylistItemId = dto.EncodePlaylistEntryID(t.ID) - return item -} - -// getPlaylist returns a playlist's visibility flag and item ids (Finamp reads OpenAccess before the -// edit screen). Get and Tracks enforce visibility; any error maps to 404 so private playlists can't -// be probed. -func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - pls, err := api.playlists.Get(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - repo, err := api.playlists.Tracks(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - // PlaylistInfo carries every track id, so this can't be paged — but it needs no track data. - trackIDs, err := repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id", Filters: notMissing}) - if err != nil { - api.internalError(w, r, err) - return - } - itemIds := slice.Map(trackIDs, dto.EncodeID) - api.ok(w, r, dto.PlaylistInfo{ - OpenAccess: pls.Public, - Shares: []dto.PlaylistUserPermissions{}, - ItemIds: itemIds, - }) -} - -// getPlaylistItems relies on Tracks to enforce visibility; any error maps to a generic 404 so a -// playlist id can't probe for private playlists. -func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - repo, err := api.playlists.Tracks(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - p := req.Params(r) - fields := dto.ParseFields(p.Strings("fields")...) - res, err := api.playlistTrackPage(ctx, repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, res) -} - -// queryIDs reads an id-list query param that clients spell two ways: comma-separated in a single -// param (Finamp: ids=X,Y) or as repeated params (Jellify's @jellyfin/sdk: ids=X&ids=Y). It returns -// the flattened, non-empty ids across both forms. -func queryIDs(r *http.Request, key string) []string { - var ids []string - for _, v := range r.URL.Query()[key] { - for id := range strings.SplitSeq(v, ",") { - if id != "" { - ids = append(ids, id) - } - } - } - return ids -} - -// expandContainerIDs expands the container ids (albums, artists, playlists) a client sends when -// building a playlist into their track ids, in order, since core/playlists only understands media -// file ids. Unknown ids pass through unchanged. Songs are classified with one batched query; only -// the rest pays per-id container probes. -func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []string { - songs := api.songsByIDs(ctx, ids) - out := make([]string, 0, len(ids)) - for _, id := range ids { - if _, ok := songs[id]; ok { - out = append(out, id) // already a song - } else if _, err := api.ds.Album().Get(ctx, id); err == nil { - out = append(out, api.songIDs(ctx, filter.SongsByAlbum(id))...) - } else if _, err := api.ds.Artist().Get(ctx, id); err == nil { - out = append(out, api.songIDs(ctx, filter.SongsByArtistID(id))...) - } else if pl, err := api.playlists.GetWithTracks(ctx, id); err == nil { - out = append(out, slice.Map(pl.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })...) - } else { - out = append(out, id) // well-formed but unresolved — left for the caller's write to handle - } - } - return out -} - -func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []string { - mfs, err := api.ds.MediaFile().GetAll(ctx, opts) - if err != nil { - log.Error(ctx, "Jellyfin: error expanding container to tracks", err) - return nil - } - return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) -} - -// addToPlaylist adds items by id (containers expand to tracks), inserting at the zero-based position -// when given. Core enforces ownership: a locked playlist maps to 403, any other error to 404. -func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - decoded, ok := dto.DecodeIDs(queryIDs(r, "ids")) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - ids := api.expandContainerIDs(ctx, decoded) - var err error - if position, perr := req.Params(r).Int64("position"); perr == nil { - _, err = api.playlists.InsertTracks(ctx, id, ids, insertPosition(position)) - } else { - _, err = api.playlists.AddTracks(ctx, id, ids) - } - if err != nil { - if errors.Is(err, model.ErrPlaylistNotEditable) { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - http.Error(w, "Not Found", http.StatusNotFound) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// insertPosition maps Jellyfin's zero-based position to a 1-based one, clamped in int64 first so -// it can't wrap on 32-bit builds. -func insertPosition(position int64) int { - return int(min(max(position, 0), math.MaxInt32-1) + 1) -} - -// removeFromPlaylist removes entries by entryIds — playlist-entry ids (PlaylistItemId), not media -// file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces -// ownership; a locked playlist maps to 403, any other error to 404. -func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - raw := queryIDs(r, "entryids") - ids := make([]string, 0, len(raw)) - for _, entryGUID := range raw { - entry, ok := dto.DecodePlaylistEntryID(entryGUID) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - ids = append(ids, entry) - } - if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil { - if errors.Is(err, model.ErrPlaylistNotEditable) { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - http.Error(w, "Not Found", http.StatusNotFound) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// movePlaylistItem moves an entry to Jellyfin's zero-based newIndex. Reorder clamps past-the-end -// indexes and rejects unknown entries, which Jellyfin treats as a no-op. -func (api *Router) movePlaylistItem(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - entry, ok := dto.DecodePlaylistEntryID(chi.URLParam(r, "entryId")) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - newIndex, err := strconv.Atoi(chi.URLParam(r, "newIndex")) - if err != nil || newIndex < 0 { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - // Resolve first, so a missing or hidden playlist is still a 404 below the unknown-entry no-op. - if _, err := api.playlists.Get(ctx, id); err != nil { - api.playlistError(w, r, err) - return - } - pos, _ := strconv.Atoi(entry) - err = api.playlists.ReorderTrack(ctx, id, pos, min(newIndex, math.MaxInt32-1)+1) - if err != nil && !errors.Is(err, model.ErrNotFound) { - api.playlistError(w, r, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// Clients probe these before offering edits. Navidrome has no per-playlist ACL, so CanEdit carries -// only editability; ownership is enforced on write, and a lookup error 404s to prevent probing. -func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - pls, err := api.playlists.Get(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - u, _ := request.UserFrom(ctx) - api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: pls.TracksEditable()}}) -} - -func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - pls, err := api.playlists.Get(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - userId := chi.URLParam(r, "userId") - api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: pls.TracksEditable()}) -} diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go deleted file mode 100644 index d38ef3b44..000000000 --- a/server/jellyfin/playlists_test.go +++ /dev/null @@ -1,577 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "io" - "math" - "net/http" - "net/http/httptest" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/filter" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// fakePlaylists is a local fake for core/playlists.Playlists. It embeds the interface so -// unimplemented methods aren't needed here; only the ones this test exercises are overridden. -type fakePlaylists struct { - playlists.Playlists - - createdName string - createdIds []string - createErr error - - getPls *model.Playlist - getErr error - tracksRepo *tests.MockPlaylistTrackRepo - - getByIDPls *model.Playlist - getByIDErr error - - addPlaylistID string - addIds []string - addErr error - - removePlaylistID string - removeIds []string - removeErr error - - setImagePlaylistID string - setImageBytes []byte - setImageExt string - setImageErr error - - removeImagePlaylistID string - removeImageErr error - - deletePlaylistID string - deleteErr error - - updatePlaylistID string - updatePublic *bool - updateErr error -} - -func (f *fakePlaylists) Update(_ context.Context, playlistID string, _ *string, _ *string, public *bool, _ []string, _ []int) error { - f.updatePlaylistID = playlistID - f.updatePublic = public - return f.updateErr -} - -func (f *fakePlaylists) Delete(_ context.Context, id string) error { - f.deletePlaylistID = id - return f.deleteErr -} - -func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []string) (string, error) { - f.createdName = name - f.createdIds = ids - if f.createErr != nil { - return "", f.createErr - } - return testID("pl-new"), nil -} - -// Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real -// service's behavior for a missing or inaccessible playlist and letting getItem tests that don't -// care about playlists leave it unconfigured. -func (f *fakePlaylists) Get(_ context.Context, _ string) (*model.Playlist, error) { - if f.getByIDErr != nil { - return nil, f.getByIDErr - } - if f.getByIDPls == nil { - return nil, model.ErrNotFound - } - return f.getByIDPls, nil -} - -func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) { - if f.getErr != nil { - return nil, f.getErr - } - if f.getPls == nil { - return nil, model.ErrNotFound // mirror the real repo: never (nil, nil) - } - return f.getPls, nil -} - -// Tracks serves the same getPls fixture as GetWithTracks. tracksRepo is kept so tests can assert -// what was pushed down to the query. -func (f *fakePlaylists) Tracks(_ context.Context, _ string) (model.PlaylistTrackRepository, error) { - if f.getErr != nil { - return nil, f.getErr - } - if f.getPls == nil { - return nil, model.ErrNotFound - } - f.tracksRepo = &tests.MockPlaylistTrackRepo{} - f.tracksRepo.SetData(f.getPls.Tracks) - return f.tracksRepo, nil -} - -func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) { - f.addPlaylistID = playlistID - f.addIds = ids - return len(ids), f.addErr -} - -func (f *fakePlaylists) RemoveTracks(_ context.Context, playlistID string, trackIds []string) error { - f.removePlaylistID = playlistID - f.removeIds = trackIds - return f.removeErr -} - -func (f *fakePlaylists) SetImage(_ context.Context, playlistID string, reader io.Reader, ext string) error { - f.setImagePlaylistID = playlistID - f.setImageExt = ext - if reader != nil { - f.setImageBytes, _ = io.ReadAll(reader) - } - return f.setImageErr -} - -func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error { - f.removeImagePlaylistID = playlistID - return f.removeImageErr -} - -var _ = DescribeTable("insertPosition", - func(position int64, want int) { - Expect(insertPosition(position)).To(Equal(want)) - }, - Entry("zero-based index becomes a 1-based position", int64(2), 3), - Entry("negative prepends", int64(-5), 1), - Entry("beyond int32 stays past the end instead of wrapping", int64(1)<<32+1, math.MaxInt32), - Entry("largest int64 stays past the end", int64(math.MaxInt64), math.MaxInt32), - Entry("smallest int64 prepends", int64(math.MinInt64), 1), -) - -var _ = Describe("Playlists", func() { - var api *Router - var fp *fakePlaylists - - BeforeEach(func() { - fp = &fakePlaylists{} - api = &Router{ds: &tests.MockDataStore{}, playlists: fp} - }) - - Describe("createPlaylist", func() { - It("creates a playlist and returns its id", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(testID("s1"))+`","`+dto.EncodeID(testID("s2"))+`"]}`)). - WithContext(context.Background()) - invoke(api.createPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res map[string]string - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res["Id"]).To(Equal(dto.EncodeID(testID("pl-new")))) - Expect(fp.createdName).To(Equal("Mix")) - Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) - }) - - It("returns 400 on an invalid JSON body", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`not json`)). - WithContext(context.Background()) - invoke(api.createPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 500 when the service fails", func() { - fp.createErr = errors.New("boom") - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix"}`)). - WithContext(context.Background()) - invoke(api.createPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - - createReq := func(body string) *http.Request { - return httptest.NewRequest("POST", "/Playlists", strings.NewReader(body)). - WithContext(GinkgoT().Context()) - } - - DescribeTable("visibility", - func(body string, wantPublic *bool, wantUpdatedID string) { - w := httptest.NewRecorder() - invoke(api.createPlaylist, w, createReq(body)) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fp.updatePublic).To(Equal(wantPublic)) - Expect(fp.updatePlaylistID).To(Equal(wantUpdatedID)) - }, - Entry("applies IsPublic true", `{"Name":"Mix","IsPublic":true}`, new(true), testID("pl-new")), - Entry("applies an explicit IsPublic false", `{"Name":"Mix","IsPublic":false}`, new(false), testID("pl-new")), - Entry("leaves visibility alone when IsPublic is omitted", `{"Name":"Mix"}`, nil, ""), - ) - - It("returns 500 when the visibility update fails", func() { - fp.updateErr = errors.New("boom") - w := httptest.NewRecorder() - invoke(api.createPlaylist, w, createReq(`{"Name":"Mix","IsPublic":true}`)) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Describe("getPlaylistItems", func() { - It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() { - fp.getPls = &model.Playlist{ - ID: testID("pl1"), - Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1"), Title: "Song One"}}, - {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2"), Title: "Song Two"}}, - }, - } - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - api.getPlaylistItems(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.TotalRecordCount).To(Equal(2)) - Expect(res.Items).To(HaveLen(2)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - Expect(res.Items[0].Type).To(Equal("Audio")) - Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1"))) - Expect(res.Items[1].Id).To(Equal(dto.EncodeID(testID("s2")))) - Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("2"))) - }) - - It("pages with StartIndex/Limit, pushing them down to the query", func() { - fp.getPls = &model.Playlist{ - ID: testID("pl1"), - Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, - {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, - {ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}}, - }, - } - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items?StartIndex=1&Limit=1", nil). - WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.getPlaylistItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.TotalRecordCount).To(Equal(3)) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2")))) - Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) - Expect(fp.tracksRepo.Options.Max).To(Equal(1)) - }) - - It("returns 404 for a non-owned or absent playlist", func() { - fp.getErr = model.ErrNotFound - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing"))) - api.getPlaylistItems(w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("container id expansion", func() { - var ds *tests.MockDataStore - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - ds = &tests.MockDataStore{} - api = &Router{ds: ds, playlists: fp} - }) - - createWith := func(id string) { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(id)+`"]}`)). - WithContext(ctx) - invoke(api.createPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - } - - It("passes a bare song id through unchanged", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}}) - createWith(testID("s1")) - Expect(fp.createdIds).To(Equal([]string{testID("s1")})) - }) - - It("expands an album id into its songs, filtered by album", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("al1")}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), AlbumID: testID("al1")}, {ID: testID("s2"), AlbumID: testID("al1")}, - }) - createWith(testID("al1")) - Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) - Expect(ds.MediaFile().(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum(testID("al1")).Filters)) - }) - - It("expands an artist id into its songs", func() { - ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1")}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}, {ID: testID("s2")}}) - createWith(testID("ar1")) - Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) - Expect(ds.MediaFile().(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID(testID("ar1")).Filters)) - }) - - It("expands a playlist id into its tracks' media file ids", func() { - fp.getPls = &model.Playlist{ID: testID("pl9"), Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s3")}, {ID: "2", MediaFileID: testID("s4")}, - }} - createWith(testID("pl9")) - Expect(fp.createdIds).To(Equal([]string{testID("s3"), testID("s4")})) - }) - }) - - Describe("getPlaylist", func() { - It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { - pls := &model.Playlist{ - ID: testID("pl1"), - Public: true, - Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, - {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, - }, - } - fp.getPls, fp.getByIDPls = pls, pls - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1"), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.getPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.PlaylistInfo - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.OpenAccess).To(BeTrue()) - Expect(res.Shares).To(BeEmpty()) - Expect(res.ItemIds).To(Equal([]string{dto.EncodeID(testID("s1")), dto.EncodeID(testID("s2"))})) - }) - - It("returns 404 for a non-owned or absent playlist", func() { - fp.getErr = model.ErrNotFound - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing"))) - invoke(api.getPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("deleteItem", func() { - deleteReq := func(id string) *http.Request { - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(id), nil).WithContext(context.Background()) - return withChiURLParam(r, "itemId", dto.EncodeID(id)) - } - - It("deletes the playlist and returns 204", func() { - w := httptest.NewRecorder() - api.deleteItem(w, deleteReq(testID("pl1"))) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.deletePlaylistID).To(Equal(testID("pl1"))) - }) - - It("returns 403 when the user doesn't own the playlist", func() { - fp.deleteErr = model.ErrNotAuthorized - w := httptest.NewRecorder() - api.deleteItem(w, deleteReq(testID("pl1"))) - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("returns 404 for a missing playlist or non-playlist id", func() { - fp.deleteErr = model.ErrNotFound - w := httptest.NewRecorder() - api.deleteItem(w, deleteReq(testID("al1"))) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 500 on an unexpected error", func() { - fp.deleteErr = errors.New("boom") - w := httptest.NewRecorder() - api.deleteItem(w, deleteReq(testID("pl1"))) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Describe("addToPlaylist", func() { - It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.addToPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addPlaylistID).To(Equal(testID("pl1"))) - Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")})) - }) - - It("accepts a PascalCase Ids param (case-folded by the middleware)", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?Ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.addToPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")})) - }) - - It("returns 404 when the service rejects the request (not found/not owned)", func() { - fp.addErr = model.ErrNotAuthorized - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.addToPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() { - fp.addErr = model.ErrPlaylistNotEditable - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.addToPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("passes no ids (not a spurious empty string) when the ids param is absent", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.addToPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addPlaylistID).To(Equal(testID("pl1"))) - Expect(fp.addIds).To(BeEmpty()) - }) - }) - - Describe("removeFromPlaylist", func() { - It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removePlaylistID).To(Equal(testID("pl1"))) - Expect(fp.removeIds).To(Equal([]string{"1", "2"})) - }) - - It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?EntryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removeIds).To(Equal([]string{"1", "2"})) - }) - - It("404s an entryId that is a song id rather than a playlist-entry position", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(fp.removeIds).To(BeEmpty()) - }) - - It("returns 404 when the service rejects the request (not found/not owned)", func() { - fp.removeErr = model.ErrNotFound - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() { - fp.removeErr = model.ErrPlaylistNotEditable - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removePlaylistID).To(Equal(testID("pl1"))) - Expect(fp.removeIds).To(BeEmpty()) - }) - }) - - Describe("getPlaylistUsers", func() { - It("returns the current user with CanEdit true for an editable playlist", func() { - fp.getByIDPls = &model.Playlist{ID: testID("pl1")} - w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - api.getPlaylistUsers(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res []dto.PlaylistUserPermissions - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID(testID("u1")), CanEdit: true}})) - }) - - It("reports CanEdit false for a synced playlist", func() { - fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true} - w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - api.getPlaylistUsers(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res []dto.PlaylistUserPermissions - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res[0].CanEdit).To(BeFalse()) - }) - - It("returns 404 when the playlist is not visible", func() { - fp.getByIDErr = model.ErrNotFound - w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - api.getPlaylistUsers(w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("getPlaylistUser", func() { - requestUser := func() *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users/"+testID("u1"), nil).WithContext(context.Background()) - rctx := chi.NewRouteContext() - rctx.URLParams.Add("playlistId", dto.EncodeID(testID("pl1"))) - rctx.URLParams.Add("userId", testID("u1")) - r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) - api.getPlaylistUser(w, r) - return w - } - - It("returns CanEdit true for an editable playlist", func() { - fp.getByIDPls = &model.Playlist{ID: testID("pl1")} - w := requestUser() - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.PlaylistUserPermissions - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: testID("u1"), CanEdit: true})) - }) - - It("reports CanEdit false for a synced playlist", func() { - fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true} - w := requestUser() - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.PlaylistUserPermissions - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.CanEdit).To(BeFalse()) - }) - }) -}) diff --git a/server/jellyfin/quickconnect.go b/server/jellyfin/quickconnect.go deleted file mode 100644 index f7fa1b979..000000000 --- a/server/jellyfin/quickconnect.go +++ /dev/null @@ -1,145 +0,0 @@ -package jellyfin - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -func requireQuickConnect(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !conf.Server.Jellyfin.QuickConnect { - http.Error(w, "Quick connect is disabled", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) -} - -func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { - api.ok(w, r, conf.Server.Jellyfin.QuickConnect) -} - -// Initiate is unauthenticated and its fields are kept for minutes, so their size must be bounded. -const maxQuickConnectField = 512 - -func (api *Router) quickConnectInitiate(w http.ResponseWriter, r *http.Request) { - a := parseMediaBrowserAuth(r) - if a.Client == "" || a.Device == "" || a.DeviceId == "" || a.Version == "" || - max(len(a.Client), len(a.Device), len(a.DeviceId), len(a.Version)) > maxQuickConnectField { - http.Error(w, "Client, Device, DeviceId and Version are required", http.StatusBadRequest) - return - } - req, err := api.quickConnect.Initiate(quickconnect.Device{ - ID: a.DeviceId, Name: a.Device, App: a.Client, AppVersion: a.Version, - }) - if errors.Is(err, quickconnect.ErrTooManyRequests) { - http.Error(w, "Too Many Requests", http.StatusTooManyRequests) - return - } - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, quickConnectResult(req)) -} - -func (api *Router) quickConnectConnect(w http.ResponseWriter, r *http.Request) { - req, err := api.quickConnect.Status(r.URL.Query().Get("secret")) - if err != nil { - http.Error(w, "Unknown secret", http.StatusNotFound) - return - } - api.ok(w, r, quickConnectResult(req)) -} - -func (api *Router) quickConnectAuthorize(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - caller, _ := request.UserFrom(ctx) - userID := caller.ID - if encoded := r.URL.Query().Get("userid"); encoded != "" { - var ok bool - if userID, ok = dto.DecodeID(encoded); !ok { - http.Error(w, "Invalid userId", http.StatusBadRequest) - return - } - } - target := caller - if userID != caller.ID { - if !caller.IsAdmin { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - usr, err := api.ds.User().Get(ctx, userID) - if errors.Is(err, model.ErrNotFound) { - http.Error(w, "Unknown user", http.StatusNotFound) - return - } - if err != nil { - api.internalError(w, r, err) - return - } - target = *usr - } - - req, err := api.quickConnect.Authorize(r.URL.Query().Get("code"), target.ID) - switch { - case errors.Is(err, model.ErrNotFound): - http.Error(w, "Unknown code", http.StatusNotFound) - case errors.Is(err, quickconnect.ErrAlreadyAuthorized): - http.Error(w, "Code already used", http.StatusConflict) - case err != nil: - api.internalError(w, r, err) - default: - log.Info(ctx, "Jellyfin API: Quick Connect sign-in approved", "username", target.UserName, - "approvedBy", caller.UserName, "client", req.Device.App, "device", req.Device.Name) - api.ok(w, r, true) - } -} - -func (api *Router) authenticateWithQuickConnect(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - var body struct { - Secret string `json:"Secret"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Secret == "" { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - userID, err := api.quickConnect.Redeem(body.Secret) - if err != nil { - http.Error(w, "Unknown secret", http.StatusNotFound) - return - } - usr, err := api.ds.User().Get(ctx, userID) - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Jellyfin API: Quick Connect user not found", "userID", userID) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - if err != nil { - api.internalError(w, r, err) - return - } - api.signIn(w, r, usr) -} - -func quickConnectResult(req quickconnect.Request) dto.QuickConnectResult { - return dto.QuickConnectResult{ - Authenticated: req.Authorized(), - Secret: req.Secret, - Code: req.Code, - DeviceId: req.Device.ID, - DeviceName: req.Device.Name, - AppName: req.Device.App, - AppVersion: req.Device.AppVersion, - DateAdded: dto.JellyfinDate(&req.DateAdded), - } -} diff --git a/server/jellyfin/quickconnect_test.go b/server/jellyfin/quickconnect_test.go deleted file mode 100644 index 94573e6ff..000000000 --- a/server/jellyfin/quickconnect_test.go +++ /dev/null @@ -1,300 +0,0 @@ -package jellyfin - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type fullQuickConnect struct{ quickconnect.QuickConnect } - -func (fullQuickConnect) Initiate(quickconnect.Device) (quickconnect.Request, error) { - return quickconnect.Request{}, quickconnect.ErrTooManyRequests -} - -var _ = Describe("QuickConnect", func() { - const finamp = `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="dev-1", Version="1.0.0"` - var ( - api *Router - ds *tests.MockDataStore - qc quickconnect.QuickConnect - alice = model.User{ID: testID("alice"), UserName: "alice"} - bob = model.User{ID: testID("bob"), UserName: "bob"} - admin = model.User{ID: testID("admin"), UserName: "admin", IsAdmin: true} - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.QuickConnect = true - ds = &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - for _, u := range []model.User{alice, bob, admin} { - Expect(ur.Put(GinkgoT().Context(), &u)).To(Succeed()) - } - qc = quickconnect.New() - api = &Router{ds: ds, quickConnect: qc} - }) - - as := func(r *http.Request, u model.User) *http.Request { - return r.WithContext(request.WithUser(r.Context(), u)) - } - initiate := func() quickconnect.Request { - req, err := qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}) - Expect(err).ToNot(HaveOccurred()) - return req - } - - Describe("GET /QuickConnect/Enabled", func() { - DescribeTable("reports the config value", - func(enabled bool, expected string) { - conf.Server.Jellyfin.QuickConnect = enabled - w := httptest.NewRecorder() - api.quickConnectEnabled(w, httptest.NewRequest("GET", "/QuickConnect/Enabled", nil)) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(MatchJSON(expected)) - }, - Entry("enabled", true, "true"), - Entry("disabled", false, "false"), - ) - }) - - Describe("requireQuickConnect", func() { - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTeapot) }) - - It("rejects requests with 401 when Quick Connect is disabled", func() { - conf.Server.Jellyfin.QuickConnect = false - w := httptest.NewRecorder() - requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("passes requests through when Quick Connect is enabled", func() { - w := httptest.NewRecorder() - requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) - Expect(w.Code).To(Equal(http.StatusTeapot)) - }) - }) - - Describe("POST /QuickConnect/Initiate", func() { - initiateWith := func(authHeader string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/QuickConnect/Initiate", nil) - r.Header.Set("Authorization", authHeader) - api.quickConnectInitiate(w, r) - return w - } - - It("returns all QuickConnectResult fields, with the device from the auth header", func() { - w := initiateWith(finamp) - - Expect(w.Code).To(Equal(http.StatusOK)) - var raw map[string]any - Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed()) - // sdk-kotlin declares every field non-null. - Expect(raw).To(HaveKeyWithValue("Authenticated", false)) - Expect(raw).To(HaveKeyWithValue("Secret", MatchRegexp(`^[0-9a-f]{64}$`))) - Expect(raw).To(HaveKeyWithValue("Code", MatchRegexp(`^\d{6}$`))) - Expect(raw).To(HaveKeyWithValue("DeviceId", "dev-1")) - Expect(raw).To(HaveKeyWithValue("DeviceName", "Pixel 7")) - Expect(raw).To(HaveKeyWithValue("AppName", "Finamp")) - Expect(raw).To(HaveKeyWithValue("AppVersion", "1.0.0")) - Expect(raw).To(HaveKeyWithValue("DateAdded", Not(BeEmpty()))) - - _, err := qc.Status(raw["Secret"].(string)) - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns 400 when the auth header does not identify the client", func() { - w := initiateWith(`MediaBrowser Client="Finamp", Device="Pixel 7", Version="1.0.0"`) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 400 when a client field is oversized", func() { - long := strings.Repeat("x", maxQuickConnectField+1) - api.quickConnect = fullQuickConnect{qc} - w := initiateWith(`MediaBrowser Client="Finamp", Device="` + long + `", DeviceId="dev-1", Version="1.0.0"`) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 429 when too many requests are pending", func() { - api.quickConnect = fullQuickConnect{qc} - Expect(initiateWith(finamp).Code).To(Equal(http.StatusTooManyRequests)) - }) - }) - - Describe("GET /QuickConnect/Connect", func() { - connect := func(secret string) (int, dto.QuickConnectResult) { - w := httptest.NewRecorder() - invoke(api.quickConnectConnect, w, httptest.NewRequest("GET", "/QuickConnect/Connect?Secret="+secret, nil)) - var res dto.QuickConnectResult - if w.Code == http.StatusOK { - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - } - return w.Code, res - } - - It("reports whether the request has been authorized", func() { - req := initiate() - code, res := connect(req.Secret) - Expect(code).To(Equal(http.StatusOK)) - Expect(res.Authenticated).To(BeFalse()) - Expect(res.Code).To(Equal(req.Code)) - - _, err := qc.Authorize(req.Code, alice.ID) - Expect(err).ToNot(HaveOccurred()) - code, res = connect(req.Secret) - Expect(code).To(Equal(http.StatusOK)) - Expect(res.Authenticated).To(BeTrue()) - }) - - It("returns 404 for an unknown secret", func() { - code, _ := connect("unknown") - Expect(code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("POST /QuickConnect/Authorize", func() { - authorize := func(query string, u model.User) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - invoke(api.quickConnectAuthorize, w, as(httptest.NewRequest("POST", "/QuickConnect/Authorize?"+query, nil), u)) - return w - } - approver := func(req quickconnect.Request) string { - got, err := qc.Status(req.Secret) - Expect(err).ToNot(HaveOccurred()) - return got.UserID - } - - It("approves the code for the caller when no userId is given", func() { - req := initiate() - w := authorize("code="+req.Code, alice) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(MatchJSON("true")) - Expect(approver(req)).To(Equal(alice.ID)) - }) - - It("accepts the caller's own userId", func() { - req := initiate() - w := authorize("Code="+req.Code+"&UserId="+dto.EncodeID(alice.ID), alice) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(approver(req)).To(Equal(alice.ID)) - }) - - It("forbids a non-admin from approving for another user", func() { - req := initiate() - w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), alice) - Expect(w.Code).To(Equal(http.StatusForbidden)) - Expect(approver(req)).To(BeEmpty()) - }) - - It("lets an admin approve for another user", func() { - req := initiate() - w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), admin) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(approver(req)).To(Equal(bob.ID)) - }) - - It("returns 404 when an admin approves for an unknown user", func() { - req := initiate() - w := authorize("code="+req.Code+"&userId="+dto.EncodeID(testID("ghost")), admin) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(approver(req)).To(BeEmpty()) - }) - - It("returns 400 for a malformed userId", func() { - req := initiate() - w := authorize("code="+req.Code+"&userId=not-a-guid", admin) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 404 for an unknown code", func() { - w := authorize("code=000000", alice) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 409 for a code that is already approved", func() { - req := initiate() - Expect(authorize("code="+req.Code, alice).Code).To(Equal(http.StatusOK)) - Expect(authorize("code="+req.Code, bob).Code).To(Equal(http.StatusConflict)) - Expect(approver(req)).To(Equal(alice.ID)) - }) - }) - - Describe("POST /Users/AuthenticateWithQuickConnect", func() { - redeem := func(body string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateWithQuickConnect", strings.NewReader(body)) - r.Header.Set("Authorization", finamp) - api.authenticateWithQuickConnect(w, r) - return w - } - redeemSecret := func(secret string) *httptest.ResponseRecorder { - return redeem(`{"Secret":"` + secret + `"}`) - } - - It("signs in the approving user once", func() { - req := initiate() - _, _ = qc.Authorize(req.Code, alice.ID) - - w := redeemSecret(req.Secret) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.AuthenticationResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.User.Name).To(Equal("alice")) - Expect(res.ServerId).ToNot(BeEmpty()) - Expect(res.SessionInfo).ToNot(BeNil()) - Expect(res.SessionInfo.DeviceId).To(Equal("dev-1")) - claims, err := auth.Validate(res.AccessToken) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Subject).To(Equal("alice")) - - Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) - }) - - It("accepts a camelCase body", func() { - req := initiate() - _, _ = qc.Authorize(req.Code, alice.ID) - Expect(redeem(`{"secret":"` + req.Secret + `"}`).Code).To(Equal(http.StatusOK)) - }) - - It("returns 404 while the request is not approved", func() { - req := initiate() - Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) - }) - - DescribeTable("returns 400 for a bad body", - func(body string) { - Expect(redeem(body).Code).To(Equal(http.StatusBadRequest)) - }, - Entry("not JSON", `nope`), - Entry("no secret", `{}`), - ) - - It("returns 401 when the approving user no longer exists", func() { - req := initiate() - _, _ = qc.Authorize(req.Code, testID("ghost")) - Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusUnauthorized)) - }) - - It("returns 500 when the user lookup fails", func() { - req := initiate() - _, _ = qc.Authorize(req.Code, alice.ID) - ds.User().(*tests.MockedUserRepo).Error = errors.New("db down") - Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusInternalServerError)) - }) - }) -}) diff --git a/server/jellyfin/response.go b/server/jellyfin/response.go deleted file mode 100644 index f4b96eda3..000000000 --- a/server/jellyfin/response.go +++ /dev/null @@ -1,87 +0,0 @@ -package jellyfin - -import ( - "bufio" - "bytes" - "encoding/json" - "io" - "iter" - "strconv" - - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -// streamItemsEnvelope writes a QueryResult, byte-identical to json.NewEncoder(w).Encode(q). -// -// A mid-stream error aborts without closing the envelope: the 200 is already committed, so a -// truncated-but-valid body would let a sync client treat the short list as the whole library and -// prune local tracks. Malformed JSON forces its parser to fail instead. Callers open the cursor -// before the first byte, so this only fires on a rare mid-iteration failure. -func streamItemsEnvelope(w io.Writer, items iter.Seq2[dto.BaseItemDto, error], total, start int) error { - bw := bufio.NewWriterSize(w, 64*1024) - _, _ = bw.WriteString(`{"Items":[`) - if err := encodeItems(bw, items); err != nil { - _ = bw.Flush() - return err - } - _, _ = bw.WriteString(`],"TotalRecordCount":`) - _, _ = bw.WriteString(strconv.Itoa(total)) - _, _ = bw.WriteString(`,"StartIndex":`) - _, _ = bw.WriteString(strconv.Itoa(start)) - _, _ = bw.WriteString("}\n") - return bw.Flush() -} - -// streamItemsArray writes a bare JSON array — the shape /Items/Latest returns, with no envelope. -func streamItemsArray(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { - bw := bufio.NewWriterSize(w, 64*1024) - _, _ = bw.WriteString("[") - if err := encodeItems(bw, items); err != nil { - _ = bw.Flush() - return err - } - _, _ = bw.WriteString("]\n") - return bw.Flush() -} - -// encodeItems writes items comma-separated. Unlike the fixed envelope writes, these are checked: -// bufio surfaces a latched write error here once a flush fails, and a client that has gone away must -// abandon the scan rather than pull the rest of the library through the cursor — which would hold its -// pooled DB connection and stream slot for a response nobody is reading. -func encodeItems(bw *bufio.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { - // One reused buffer+encoder, so per-item JSON doesn't allocate. Encode HTML-escapes like - // json.Marshal, and appends a newline that's dropped below. - var itemBuf bytes.Buffer - enc := json.NewEncoder(&itemBuf) - first := true - for item, err := range items { - if err != nil { - return err - } - if !first { - if _, err := bw.WriteString(","); err != nil { - return err - } - } - first = false - itemBuf.Reset() - if err := enc.Encode(item); err != nil { - return err - } - b := itemBuf.Bytes() - if _, err := bw.Write(b[:len(b)-1]); err != nil { - return err - } - } - return nil -} - -func sliceItems(items []dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { - return func(yield func(dto.BaseItemDto, error) bool) { - for i := range items { - if !yield(items[i], nil) { - return - } - } - } -} diff --git a/server/jellyfin/response_test.go b/server/jellyfin/response_test.go deleted file mode 100644 index c32c566fc..000000000 --- a/server/jellyfin/response_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package jellyfin - -import ( - "bytes" - "encoding/json" - "errors" - "iter" - "strings" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// deadWriter stands in for a client that went away mid-response. -type deadWriter struct{} - -func (deadWriter) Write([]byte) (int, error) { return 0, errors.New("connection reset by peer") } - -var _ = Describe("streaming a materialized QueryResult", func() { - // The materialized path (api.ok -> writeItems -> sliceItems) must stay byte-for-byte identical to - // what json.Encoder.Encode produced before, so no client sees a different response. - assertIdenticalToEncoder := func(q dto.QueryResult) { - var got bytes.Buffer - Expect(streamItemsEnvelope(&got, sliceItems(q.Items), q.TotalRecordCount, q.StartIndex)).To(Succeed()) - - var want bytes.Buffer - Expect(json.NewEncoder(&want).Encode(q)).To(Succeed()) - - Expect(got.String()).To(Equal(want.String())) - } - - It("encodes an empty item list", func() { - assertIdenticalToEncoder(dto.QueryResult{Items: []dto.BaseItemDto{}}) - }) - - It("encodes a single item", func() { - assertIdenticalToEncoder(dto.QueryResult{ - Items: []dto.BaseItemDto{{Id: "a", Name: "One"}}, - TotalRecordCount: 1, - }) - }) - - It("encodes multiple items, honoring HTML escaping and StartIndex", func() { - assertIdenticalToEncoder(dto.QueryResult{ - Items: []dto.BaseItemDto{ - {Id: "a", Name: "One"}, - {Id: "b", Name: "Two & "}, - }, - TotalRecordCount: 500, - StartIndex: 100, - }) - }) -}) - -var _ = Describe("streamItemsEnvelope", func() { - seqOf := func(items ...dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { - return func(yield func(dto.BaseItemDto, error) bool) { - for _, it := range items { - if !yield(it, nil) { - return - } - } - } - } - - It("produces the same bytes as encoding an equivalent QueryResult", func() { - items := []dto.BaseItemDto{{Id: "a", Name: "One"}, {Id: "b", Name: "Two & "}} - var got bytes.Buffer - Expect(streamItemsEnvelope(&got, seqOf(items...), 500, 100)).To(Succeed()) - - var want bytes.Buffer - Expect(json.NewEncoder(&want).Encode(dto.QueryResult{Items: items, TotalRecordCount: 500, StartIndex: 100})).To(Succeed()) - Expect(got.String()).To(Equal(want.String())) - }) - - It("emits an empty array (not null) for a sequence that yields nothing", func() { - var got bytes.Buffer - Expect(streamItemsEnvelope(&got, seqOf(), 0, 0)).To(Succeed()) - Expect(got.String()).To(Equal("{\"Items\":[],\"TotalRecordCount\":0,\"StartIndex\":0}\n")) - }) - - // A client that goes away must not keep the source (a DB cursor, holding its pooled connection - // and a stream slot) running to the end of the library. - It("stops pulling from the source once writing fails", func() { - const total = 20000 - pulled := 0 - seq := func(yield func(dto.BaseItemDto, error) bool) { - for range total { - pulled++ - if !yield(dto.BaseItemDto{Id: "a", Name: strings.Repeat("x", 200)}, nil) { - return - } - } - } - err := streamItemsEnvelope(deadWriter{}, seq, total, 0) - Expect(err).To(HaveOccurred()) - Expect(pulled).To(BeNumerically("<", total), "should abandon the scan, not drain it") - }) - - It("aborts on a mid-stream error, leaving the envelope open (malformed) so the client fails loudly", func() { - boom := errors.New("scan failed") - first := dto.BaseItemDto{Id: "a", Name: "One"} - seq := func(yield func(dto.BaseItemDto, error) bool) { - if !yield(first, nil) { - return - } - yield(dto.BaseItemDto{}, boom) - } - var got bytes.Buffer - err := streamItemsEnvelope(&got, seq, 7, 0) - Expect(err).To(MatchError(boom)) - firstJSON, _ := json.Marshal(first) - Expect(got.String()).To(Equal("{\"Items\":[" + string(firstJSON))) - }) -}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go deleted file mode 100644 index 70da5b1eb..000000000 --- a/server/jellyfin/routing_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package jellyfin - -import ( - "net/http" - "net/http/httptest" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Real Jellyfin servers route path segments case-insensitively, but chi's default matching is -// case-sensitive. Jellyfin wires up server.CaseInsensitivePaths (see server/case_insensitive_routes.go -// for the unit-level tests of that helper) to work around this. These tests are an end-to-end proof -// that requests using non-canonical casing are still routed correctly, both when the router is used -// directly and when mounted under a parent (as it is in production via server.MountRouter). -var _ = Describe("Case-insensitive routing", func() { - var api *Router - - BeforeEach(func() { - api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - }) - - It("serves a fully lowercase path directly", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/system/info/public", nil) - api.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("serves a mixed/weird-case path directly", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/SYSTEM/Info/PUBLIC", nil) - api.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("serves a lowercase login path directly", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/users/authenticatebyname", nil) - api.ServeHTTP(w, r) - // MockDataStore has no users, so authentication itself may fail downstream, but the - // route must be found (not a 404) to prove case-insensitive matching worked. - Expect(w.Code).ToNot(Equal(http.StatusNotFound)) - }) - - It("serves a lowercase path when mounted under a parent router, replicating production", func() { - parent := chi.NewRouter() - parent.Mount("/jellyfin", api) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/jellyfin/system/info/public", nil) - parent.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) -}) diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go deleted file mode 100644 index e60b1bf5b..000000000 --- a/server/jellyfin/sessions.go +++ /dev/null @@ -1,120 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -// playbackReport is the subset of Jellyfin's PlaybackStartInfo/PlaybackProgressInfo -// fields Navidrome needs to keep its playback/scrobbling state in sync. -type playbackReport struct { - ItemId string `json:"ItemId"` - PositionTicks int64 `json:"PositionTicks"` - IsPaused bool `json:"IsPaused"` -} - -// decodeReport reads the playback report body. ItemId falls back to a query param (some clients send -// it there) and is decoded here since it flows straight into scrobbler lookups by media file id. -// These endpoints always answer 204 (see reportPlaybackStart), so a malformed id isn't a request -// failure; it just can't identify a track, same as an absent one. -func (api *Router) decodeReport(r *http.Request) playbackReport { - var body playbackReport - _ = json.NewDecoder(r.Body).Decode(&body) - if body.ItemId == "" { - body.ItemId = r.URL.Query().Get("itemid") - } - if id, ok := dto.DecodeID(body.ItemId); ok { - body.ItemId = id - } else { - body.ItemId = "" - } - return body -} - -// clientIdentity returns the scrobbler cache key/display name for the caller's -// player. Both are zero values if withPlayer could not resolve a player. -func clientIdentity(ctx context.Context) (id, name string) { - player, _ := request.PlayerFrom(ctx) - return player.ID, player.Client -} - -// reportPlaybackStart handles POST /Sessions/Playing, sent once when a client starts an item. -// -// These Sessions endpoints report only the caller's own playback and never expose content, so unlike -// browse/stream they are intentionally not library-access-gated. -func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - body := api.decodeReport(r) - clientId, clientName := clientIdentity(ctx) - err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ - MediaId: body.ItemId, - PositionMs: dto.MillisFromTicks(body.PositionTicks), - State: scrobbler.StatePlaying, - PlaybackRate: 1.0, - ClientId: clientId, - ClientName: clientName, - }) - if err != nil { - log.Warn(ctx, "Jellyfin API: report playback start failed", "id", body.ItemId, err) - } - w.WriteHeader(http.StatusNoContent) -} - -// reportPlaybackProgress handles POST /Sessions/Playing/Progress, sent periodically -// (and on pause/resume/seek) while a client keeps playing an item. -func (api *Router) reportPlaybackProgress(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - body := api.decodeReport(r) - state := scrobbler.StatePlaying - if body.IsPaused { - state = scrobbler.StatePaused - } - clientId, clientName := clientIdentity(ctx) - err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ - MediaId: body.ItemId, - PositionMs: dto.MillisFromTicks(body.PositionTicks), - State: state, - PlaybackRate: 1.0, - ClientId: clientId, - ClientName: clientName, - }) - if err != nil { - log.Warn(ctx, "Jellyfin API: report playback progress failed", "id", body.ItemId, err) - } - w.WriteHeader(http.StatusNoContent) -} - -// reportPlaybackStopped handles POST /Sessions/Playing/Stopped, sent once when playback ends. -// -// Jellyfin clients (Finamp) send a Stopped report on *every* stop, even an immediate track switch, -// so the play threshold is applied server-side: ReportPlayback's StateStopped logic counts the play -// only past 50% (capped at 4 minutes). Force-submitting here would mark a one-second skip as played. -func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - body := api.decodeReport(r) - clientId, clientName := clientIdentity(ctx) - - err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ - MediaId: body.ItemId, - PositionMs: dto.MillisFromTicks(body.PositionTicks), - State: scrobbler.StateStopped, - ClientId: clientId, - ClientName: clientName, - }) - if err != nil { - log.Warn(ctx, "Jellyfin API: report playback stopped failed", "id", body.ItemId, err) - } - w.WriteHeader(http.StatusNoContent) -} - -// acknowledge answers requests Navidrome keeps no state for: session capabilities (not tracked per -// session) and playback pings (transcodes live only as long as their stream request). -func (api *Router) acknowledge(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) -} diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go deleted file mode 100644 index f2f04497c..000000000 --- a/server/jellyfin/sessions_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package jellyfin - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// fakePlayTracker is a local double for scrobbler.PlayTracker, mirroring -// server/subsonic's fakePlayTracker. -type fakePlayTracker struct { - scrobbler.PlayTracker - reported []scrobbler.ReportPlaybackParams - submitted []scrobbler.Submission -} - -func (f *fakePlayTracker) ReportPlayback(_ context.Context, p scrobbler.ReportPlaybackParams) error { - f.reported = append(f.reported, p) - return nil -} - -func (f *fakePlayTracker) Submit(_ context.Context, s []scrobbler.Submission) error { - f.submitted = append(f.submitted, s...) - return nil -} - -// fakePlayers is a local double for core.Players, used to exercise withPlayer. -type fakePlayers struct { - core.Players - err error - registerCalls int - lastClient string - trc *model.Transcoding -} - -func (f *fakePlayers) Register(_ context.Context, id, client, _, _ string) (*model.Player, *model.Transcoding, error) { - f.registerCalls++ - f.lastClient = client - if f.err != nil { - return nil, nil, f.err - } - return &model.Player{ID: id, Client: client}, f.trc, nil -} - -var _ = Describe("Sessions", func() { - var api *Router - var pt *fakePlayTracker - - authed := func(r *http.Request) *http.Request { - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) - ctx = request.WithPlayer(ctx, model.Player{ID: testID("p1"), Client: "Finamp"}) - return r.WithContext(ctx) - } - - BeforeEach(func() { - pt = &fakePlayTracker{} - api = &Router{ds: &tests.MockDataStore{}, scrobbler: pt} - }) - - Describe("reportPlaybackStart", func() { - It("reports playback start with the item id and position", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":10000000}`))) - - invoke(api.reportPlaybackStart, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal(testID("s1"))) - Expect(pt.reported[0].PositionMs).To(Equal(int64(1000))) - Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) - Expect(pt.reported[0].ClientId).To(Equal(testID("p1"))) - Expect(pt.reported[0].ClientName).To(Equal("Finamp")) - }) - - It("falls back to the ItemId query param when the body has none", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId="+dto.EncodeID(testID("s2")), nil)) - - invoke(api.reportPlaybackStart, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal(testID("s2"))) - }) - - It("still reports (with an empty MediaId) for a malformed ItemId, rather than failing the request", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"not-a-valid-id","PositionTicks":10000000}`))) - - invoke(api.reportPlaybackStart, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(BeEmpty()) - }) - }) - - Describe("reportPlaybackProgress", func() { - It("reports the playing state when not paused", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":false}`))) - - invoke(api.reportPlaybackProgress, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) - Expect(pt.reported[0].PositionMs).To(Equal(int64(2000))) - }) - - It("reports the paused state when IsPaused is true", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":true}`))) - - invoke(api.reportPlaybackProgress, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].State).To(Equal(scrobbler.StatePaused)) - }) - }) - - Describe("reportPlaybackStopped", func() { - It("reports the stopped state and lets the scrobbler apply its play threshold", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":600000000}`))) - - invoke(api.reportPlaybackStopped, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal(testID("s1"))) - Expect(pt.reported[0].State).To(Equal(scrobbler.StateStopped)) - Expect(pt.reported[0].PositionMs).To(Equal(int64(60000))) - // IgnoreScrobble stays false so ReportPlayback's own StateStopped threshold decides - // whether the play counts; we no longer force a Submit that would bypass it. - Expect(pt.reported[0].IgnoreScrobble).To(BeFalse()) - Expect(pt.submitted).To(BeEmpty()) - }) - }) - - Describe("acknowledge", func() { - It("returns 204 No Content and does not touch the scrobbler", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Capabilities", strings.NewReader(`{"SupportsMediaControl":true}`))) - - api.acknowledge(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(BeEmpty()) - Expect(pt.submitted).To(BeEmpty()) - }) - }) -}) - -var _ = Describe("withPlayer middleware", func() { - var api *Router - var fp *fakePlayers - - BeforeEach(func() { - fp = &fakePlayers{} - api = &Router{ds: &tests.MockDataStore{}, players: fp} - }) - - It("registers a player from the Emby device info and injects it into the context", func() { - var gotPlayer model.Player - var gotOk bool - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPlayer, gotOk = request.PlayerFrom(r.Context()) - w.WriteHeader(http.StatusNoContent) - }) - - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Sessions/Playing", nil) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) - - api.withPlayer(next).ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(gotOk).To(BeTrue()) - Expect(gotPlayer.ID).To(Equal("dev1")) - Expect(gotPlayer.Client).To(Equal("Finamp")) - }) - - It("fails open (no player in context) when registration errors", func() { - fp.err = errors.New("boom") - var gotOk bool - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, gotOk = request.PlayerFrom(r.Context()) - w.WriteHeader(http.StatusNoContent) - }) - - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Sessions/Playing", nil) - - api.withPlayer(next).ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(gotOk).To(BeFalse()) - }) - - // The /socket handshake authenticates via ?api_key= with no X-Emby-Authorization header, so it - // carries no client/device info; registering it would create a junk player named " []". - It("skips registration when the request has no client or device info", func() { - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/socket?api_key=tok", nil) - - api.withPlayer(next).ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.registerCalls).To(Equal(0)) - }) -}) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go deleted file mode 100644 index 50503160c..000000000 --- a/server/jellyfin/similar.go +++ /dev/null @@ -1,227 +0,0 @@ -package jellyfin - -import ( - "context" - "errors" - "fmt" - "net/http" - "time" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" -) - -// similarWait bounds how long a Similar request waits for the provider fetch. Returning the real -// result beats an instant empty list, which clients cache as "no similar items exist". A var so -// tests can shorten it. -var similarWait = 10 * time.Second - -const ( - defaultSimilarLimit = 20 - maxSimilarLimit = 100 - // A mix is a playback queue, not a "related items" list: Finamp's Radio Mix asks for 250, so the - // Similar ceiling would truncate it. Real Jellyfin builds mixes from a 200-track genre query. - maxInstantMixLimit = 500 -) - -// similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine -// indefinitely. -const similarFetchTimeout = time.Minute - -// awaitSimilar runs fetch on a detached background context (so it completes and caches even if the -// request times out or the client disconnects), waiting up to similarWait then answering empty. -// Identical concurrent requests share one fetch via singleflight; the key includes the user since -// mapped items embed that user's annotations. -func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch func(context.Context) dto.QueryResult) dto.QueryResult { - u, _ := request.UserFrom(ctx) - key := fmt.Sprintf("%s|%s|%d", u.ID, id, limit) - ch := api.similarFlight.DoChan(key, func() (any, error) { - bgCtx, cancel := context.WithTimeout(request.WithUser(context.Background(), u), similarFetchTimeout) - defer cancel() - return fetch(bgCtx), nil - }) - select { - case res := <-ch: - return res.Val.(dto.QueryResult) - case <-time.After(similarWait): - return result(nil, 0, 0) - } -} - -// getSimilarArtists answers GET /Artists/{itemId}/Similar with related artists from the same -// external.Provider that powers Subsonic's getArtistInfo2. Only artists present in the library are -// returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying. -func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) - api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult { - return api.similarArtists(ctx, id, limit) - })) -} - -// getSimilarItems answers GET /Items/{itemId}/Similar with items of the target's kind: similar -// songs for a track, albums for an album, artists for an artist. An unresolvable id yields an empty -// result (not 404) so the client stops retrying. -func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) - - entity, err := model.GetEntityByID(ctx, api.ds, id) - if err != nil { - api.ok(w, r, result(nil, 0, 0)) - return - } - api.ok(w, r, api.awaitSimilar(ctx, id, limit, func(ctx context.Context) dto.QueryResult { - switch entity.(type) { - case *model.Artist: - return api.similarArtists(ctx, id, limit) - case *model.Album: - return api.similarAlbums(ctx, id, limit) - default: // *model.MediaFile - return api.similarSongs(ctx, id, limit) - } - })) -} - -// getSimilarAlbums answers GET /Albums/{itemId}/Similar, powering Finamp's albumMix radio mode. -func (api *Router) getSimilarAlbums(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) - api.ok(w, r, api.awaitSimilar(ctx, "albsim|"+id, limit, func(ctx context.Context) dto.QueryResult { - return api.similarAlbums(ctx, id, limit) - })) -} - -// getInstantMix answers GET /Items/{itemId}/InstantMix. Finamp plays exactly what is returned, so -// a track seed leads its own mix; provider errors and unknown seeds degrade to seed-only/empty -// results, never a 404 the client would surface as an error. -func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - api.instantMix(w, r, id) -} - -// getInstantMixByQuery serves the legacy Artists/InstantMix and MusicGenres/InstantMix forms, -// which take the seed as ?id= instead of a path segment. -func (api *Router) getInstantMixByQuery(w http.ResponseWriter, r *http.Request) { - id, ok := dto.DecodeID(req.Params(r).StringOr("id", "")) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - api.instantMix(w, r, id) -} - -func (api *Router) instantMix(w http.ResponseWriter, r *http.Request, id string) { - ctx := r.Context() - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) - - // Genre ids don't resolve via GetEntityByID, so a not-found entity is fine: it is just "not a - // song" and the provider knows what to do with it. A real lookup failure still stops here. - entity, err := model.GetEntityByID(ctx, api.ds, id) - if err != nil && !errors.Is(err, model.ErrNotFound) { - api.ok(w, r, result(nil, 0, 0)) - return - } - mf, isSong := entity.(*model.MediaFile) - if isSong { - if u, _ := request.UserFrom(ctx); !u.HasLibraryAccess(mf.LibraryID) { - api.ok(w, r, result(nil, 0, 0)) - return - } - } - // Prefixed key: a mix must not share the singleflight/cache slot with a Similar request. - tail := api.awaitSimilar(ctx, "mix|"+id, limit, func(ctx context.Context) dto.QueryResult { - return api.similarSongs(ctx, id, limit) - }) - if !isSong { - // Container seeds: the provider's similar songs already blend the seed's own tracks. - api.ok(w, r, tail) - return - } - // The seed leads the mix and must not depend on the provider: a slow or failing provider times - // the await out with an empty tail, but the tapped track still plays. - items := []dto.BaseItemDto{dto.SongToBaseItem(*mf, nil)} - for _, it := range tail.Items { - if len(items) >= limit { - break - } - if it.Id != items[0].Id { - items = append(items, it) - } - } - api.ok(w, r, result(items, len(items), 0)) -} - -func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto.QueryResult { - artist, err := api.provider.UpdateArtistInfo(ctx, id, limit, false) - if err != nil { - log.Debug(ctx, "Jellyfin API: no similar artists", "id", id, err) - return result(nil, 0, 0) - } - present := slice.Filter(artist.SimilarArtists, func(a model.Artist) bool { return a.ID != "" }) - items := slice.Map(present, func(a model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(a, nil) }) - return result(items, len(items), 0) -} - -func (api *Router) similarSongs(ctx context.Context, id string, limit int) dto.QueryResult { - songs, err := api.provider.SimilarSongs(ctx, id, limit) - if err != nil { - log.Debug(ctx, "Jellyfin API: no similar songs", "id", id, err) - return result(nil, 0, 0) - } - // Filter to the caller's libraries; the provider can return songs from any library. - u, _ := request.UserFrom(ctx) - var items []dto.BaseItemDto - for _, mf := range songs { - if u.HasLibraryAccess(mf.LibraryID) { - items = append(items, dto.SongToBaseItem(mf, nil)) - } - } - return result(items, len(items), 0) -} - -// similarAlbums derives similar albums from the provider's similar-songs signal (there's no direct -// "similar albums" source), keeping each album once in first-seen order and resolving it to a full -// model.Album for cover art and metadata. -func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto.QueryResult { - songs, err := api.provider.SimilarSongs(ctx, id, limit*5) - if err != nil { - log.Debug(ctx, "Jellyfin API: no similar albums", "id", id, err) - return result(nil, 0, 0) - } - u, _ := request.UserFrom(ctx) - // An album is not similar to itself, and the sampled-seed fallback returns its own tracks. - seen := map[string]bool{id: true} - var items []dto.BaseItemDto - for _, s := range songs { - if s.AlbumID == "" || seen[s.AlbumID] { - continue - } - seen[s.AlbumID] = true - if al, err := api.ds.Album().Get(ctx, s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { - items = append(items, dto.AlbumToBaseItem(*al, nil)) - if len(items) >= limit { - break - } - } - } - return result(items, len(items), 0) -} diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go deleted file mode 100644 index 42d04a632..000000000 --- a/server/jellyfin/similar_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "fmt" - "net/http/httptest" - "strconv" - "sync/atomic" - "time" - - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("awaitSimilar", func() { - var api *Router - ctxFor := func(userID string) context.Context { - return request.WithUser(context.Background(), model.User{ID: userID}) - } - shortenWait := func() { - old := similarWait - similarWait = 20 * time.Millisecond - DeferCleanup(func() { similarWait = old }) - } - - BeforeEach(func() { - api = &Router{} - }) - - It("returns the fetch result when it completes within the wait", func() { - res := api.awaitSimilar(ctxFor(testID("u1")), "id1", 20, func(context.Context) dto.QueryResult { - return result([]dto.BaseItemDto{{Name: "fast"}}, 1, 0) - }) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Name).To(Equal("fast")) - }) - - It("returns an empty result when the fetch exceeds the wait", func() { - shortenWait() - release := make(chan struct{}) - DeferCleanup(func() { close(release) }) - res := api.awaitSimilar(ctxFor(testID("u1")), "id2", 20, func(context.Context) dto.QueryResult { - <-release // hung provider; would finish caching in the background - return result([]dto.BaseItemDto{{Name: "late"}}, 1, 0) - }) - Expect(res.Items).To(BeEmpty()) - Expect(res.TotalRecordCount).To(Equal(0)) - }) - - It("dedupes requests into the in-flight fetch", func() { - shortenWait() - var calls atomic.Int32 - release := make(chan struct{}) - fetch := func(context.Context) dto.QueryResult { - calls.Add(1) - <-release - return result(nil, 0, 0) - } - // Both calls time out, but the flight can't complete before release closes, so the - // second call must join it rather than start a new fetch. - api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch) - api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch) - close(release) - Eventually(calls.Load).Should(Equal(int32(1))) - Consistently(calls.Load, "50ms").Should(Equal(int32(1))) - }) - - It("does not share fetches across users (items embed the user's annotations)", func() { - var calls atomic.Int32 - fetch := func(context.Context) dto.QueryResult { - calls.Add(1) - return result(nil, 0, 0) - } - api.awaitSimilar(ctxFor(testID("u1")), "id4", 20, fetch) - api.awaitSimilar(ctxFor(testID("u2")), "id4", 20, fetch) - Expect(calls.Load()).To(Equal(int32(2))) - }) - - It("hands the fetch a deadline-bounded background context", func() { - var deadline time.Time - var hasDeadline bool - api.awaitSimilar(ctxFor(testID("u1")), "id5", 20, func(ctx context.Context) dto.QueryResult { - deadline, hasDeadline = ctx.Deadline() - return result(nil, 0, 0) - }) - Expect(hasDeadline).To(BeTrue(), "background fetch must not be able to run forever") - Expect(time.Until(deadline)).To(BeNumerically("<=", similarFetchTimeout)) - }) -}) - -// blockingProvider hangs SimilarSongs until release is closed, simulating a slow/unreachable agent. -type blockingProvider struct { - external.Provider - release chan struct{} -} - -func (p *blockingProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { - <-p.release - return nil, nil -} - -// fakeSimilarProvider returns up to count of its canned songs, like a real agent honoring the limit. -type fakeSimilarProvider struct { - external.Provider - songs model.MediaFiles -} - -func (p *fakeSimilarProvider) SimilarSongs(_ context.Context, _ string, count int) (model.MediaFiles, error) { - return p.songs[:min(count, len(p.songs))], nil -} - -var _ = Describe("getInstantMix", func() { - It("returns the seed track even when the provider fetch exceeds the wait", func() { - old := similarWait - similarWait = 20 * time.Millisecond - DeferCleanup(func() { similarWait = old }) - - ds := &tests.MockDataStore{} - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Seed Song", LibraryID: 1}, - }) - release := make(chan struct{}) - DeferCleanup(func() { close(release) }) - api := &Router{ds: ds, provider: &blockingProvider{release: release}} - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix", nil). - WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.getInstantMix(w, r) - - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Name).To(Equal("Seed Song")) - }) - - // Finamp's Radio Mix asks for limit=250. Clamping that to the Similar ceiling (100) truncated the - // queue, so InstantMix gets its own, higher ceiling. - It("honors a mix-sized limit above the Similar ceiling", func() { - const want = 250 - songs := model.MediaFiles{{ID: testID("s1"), Title: "Seed Song", LibraryID: 1}} - for i := range want + 50 { // more than requested, so only the limit bounds the result - songs = append(songs, model.MediaFile{ID: testID(fmt.Sprintf("t%d", i)), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) - } - ds := &tests.MockDataStore{} - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}} - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix?limit="+strconv.Itoa(want), nil). - WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.getInstantMix(w, r) - - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(want), "a Radio Mix-sized request must not be truncated to the Similar ceiling") - Expect(res.Items[0].Name).To(Equal("Seed Song"), "the seed must still lead the mix") - }) - - It("returns a mix for a genre id, which GetEntityByID can't resolve", func() { - ds := &tests.MockDataStore{} - songs := model.MediaFiles{ - {ID: testID("m1"), Title: "Track 1", LibraryID: 1}, - {ID: testID("m2"), Title: "Track 2", LibraryID: 1}, - } - api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs}} - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1"))+"/InstantMix", nil). - WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1"))) - api.getInstantMix(w, r) - - Expect(w.Code).To(Equal(200)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - }) -}) - -var _ = Describe("getSimilarAlbums", func() { - It("does not return the seed album as its own similar album", func() { - // With no external agent the provider falls back to the album's own tracks, which map - // straight back to the requested album. - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: testID("al-1"), Name: "Seed Album", LibraryID: 1}, - }) - api := &Router{ds: ds, provider: &fakeSimilarProvider{ - songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-1"), LibraryID: 1}}, - }} - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil). - WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1"))) - api.getSimilarAlbums(w, r) - - Expect(w.Code).To(Equal(200)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(BeEmpty()) - }) - - It("returns albums derived from the provider's similar songs", func() { - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: testID("al-2"), Name: "Other", LibraryID: 1}, - }) - api := &Router{ds: ds, provider: &fakeSimilarProvider{ - songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-2"), LibraryID: 1}}, - }} - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil). - WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1"))) - api.getSimilarAlbums(w, r) - - Expect(w.Code).To(Equal(200)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Name).To(Equal("Other")) - }) -}) diff --git a/server/jellyfin/socket.go b/server/jellyfin/socket.go deleted file mode 100644 index 80a49cd4c..000000000 --- a/server/jellyfin/socket.go +++ /dev/null @@ -1,55 +0,0 @@ -package jellyfin - -import ( - "net/http" - "time" - - "github.com/gorilla/websocket" - "github.com/navidrome/navidrome/log" -) - -// socketKeepAliveInterval (seconds) is sent in the initial ForceKeepAlive telling the client how -// often to send KeepAlive, and bounds the local read deadline. -const socketKeepAliveInterval = 60 - -// socketReadTimeout is generous relative to socketKeepAliveInterval so a single delayed -// KeepAlive doesn't drop the connection. -const socketReadTimeout = 90 * time.Second - -var socketUpgrader = websocket.Upgrader{ - // Jellyfin clients aren't browsers, so there's no cross-origin risk; the connection is - // already authenticated via api_key. - CheckOrigin: func(*http.Request) bool { return true }, -} - -// handleSocket implements Jellyfin's /socket WebSocket endpoint. Finamp opens it right after login -// and 404-loop-reconnects without it. Minimal: keeps the connection alive and answers KeepAlive -// pings, with no session/playstate push. -func (api *Router) handleSocket(w http.ResponseWriter, r *http.Request) { - conn, err := socketUpgrader.Upgrade(w, r, nil) - if err != nil { - log.Warn(r.Context(), "Jellyfin API: WebSocket upgrade failed", err) - return - } - defer conn.Close() - - if err := conn.WriteJSON(map[string]any{"MessageType": "ForceKeepAlive", "Data": socketKeepAliveInterval}); err != nil { - log.Warn(r.Context(), "Jellyfin API: WebSocket failed to send ForceKeepAlive", err) - return - } - - for { - _ = conn.SetReadDeadline(time.Now().Add(socketReadTimeout)) - var msg struct { - MessageType string `json:"MessageType"` - } - if err := conn.ReadJSON(&msg); err != nil { - return - } - if msg.MessageType == "KeepAlive" { - if err := conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"}); err != nil { - return - } - } - } -} diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go deleted file mode 100644 index 79098aafd..000000000 --- a/server/jellyfin/socket_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package jellyfin - -import ( - "net/http" - "net/http/httptest" - "strings" - "time" - - "github.com/gorilla/websocket" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("handleSocket", func() { - var api *Router - - BeforeEach(func() { - api = &Router{} - }) - - // Jellyfin's real-time clients (e.g. Finamp) open a WebSocket right after login; without - // a working handshake here they 404-loop-reconnect instead of settling into a session. - It("upgrades the connection and sends ForceKeepAlive", func() { - srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) - defer srv.Close() - - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - Expect(err).ToNot(HaveOccurred()) - defer conn.Close() - - Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) - var msg map[string]any - Expect(conn.ReadJSON(&msg)).To(Succeed()) - Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) - Expect(msg["Data"]).To(BeNumerically("==", 60)) - }) - - It("replies to a KeepAlive message with a KeepAlive of its own", func() { - srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) - defer srv.Close() - - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - Expect(err).ToNot(HaveOccurred()) - defer conn.Close() - - Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) - var handshake map[string]any - Expect(conn.ReadJSON(&handshake)).To(Succeed()) - Expect(handshake["MessageType"]).To(Equal("ForceKeepAlive")) - - Expect(conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"})).To(Succeed()) - - Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) - var reply map[string]any - Expect(conn.ReadJSON(&reply)).To(Succeed()) - Expect(reply["MessageType"]).To(Equal("KeepAlive")) - }) - - It("closes the connection when the client disconnects, without leaving the handler hanging", func() { - srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) - defer srv.Close() - - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - Expect(err).ToNot(HaveOccurred()) - - Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) - var handshake map[string]any - Expect(conn.ReadJSON(&handshake)).To(Succeed()) - - Expect(conn.Close()).To(Succeed()) - }) - - // End-to-end: proves /socket is reachable through the full router (case-insensitive - // wrapper + chi mux + auth middleware) with a real network listener, exactly as Finamp - // hits it in production with ?api_key=. - Context("mounted behind the full router and auth middleware", func() { - var ds *tests.MockDataStore - var token string - - BeforeEach(func() { - ds = &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(GinkgoT().Context(), &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) - - t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"}) - Expect(err).ToNot(HaveOccurred()) - token = t - - api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - }) - - It("upgrades when authenticated via the api_key query parameter", func() { - srv := httptest.NewServer(api) - defer srv.Close() - - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket?api_key=" + token - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - Expect(err).ToNot(HaveOccurred()) - defer conn.Close() - - Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) - var msg map[string]any - Expect(conn.ReadJSON(&msg)).To(Succeed()) - Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) - }) - - It("rejects the upgrade with no api_key", func() { - srv := httptest.NewServer(api) - defer srv.Close() - - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket" - _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) - Expect(err).To(HaveOccurred()) - Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) - }) - }) -}) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go deleted file mode 100644 index 515a6fcb8..000000000 --- a/server/jellyfin/stream.go +++ /dev/null @@ -1,217 +0,0 @@ -package jellyfin - -import ( - "cmp" - "fmt" - "math" - "mime" - "net/http" - "net/url" - "slices" - "strconv" - "strings" - - "github.com/go-chi/chi/v5" - "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/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" -) - -// mediaFileForRequest resolves {itemId} to a MediaFile and verifies the user has access to its -// library, writing 404 (never 403, to avoid an existence oracle) and returning ok=false otherwise. -// Shared by getPlaybackInfo and streamAudio so a guessed id can't probe or stream another library. -func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) (*model.MediaFile, bool) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return nil, false - } - mf, err := api.ds.MediaFile().Get(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return nil, false - } - u, _ := request.UserFrom(ctx) - if !u.HasLibraryAccess(mf.LibraryID) { - http.Error(w, "Not Found", http.StatusNotFound) - return nil, false - } - return mf, true -} - -// getPlaybackInfo answers /Items/{itemId}/PlaybackInfo with a single MediaSource for direct -// playback. Format negotiation happens later in streamAudio (like Subsonic defers it to /stream). -func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - src := dto.MediaSourceFromMediaFile(*mf) - // The mapper only sees embedded lyrics; per-track we can afford the full pipeline - // (sidecars, plugins) so Finamp's Lyric-stream gate reflects every source. - if !slices.ContainsFunc(src.MediaStreams, func(s dto.MediaStream) bool { return s.Type == "Lyric" }) { - if _, found := servableLyric(api.cachedLyrics(r.Context(), mf)); found { - src.MediaStreams = append(src.MediaStreams, dto.MediaStream{ - Type: "Lyric", Index: len(src.MediaStreams), IsExternal: true, - }) - } - } - // Self-authenticating: native players fetch this without an auth header. Server-relative: - // clients append it to a base URL already carrying /jellyfin. - if token := tokenFromRequest(r); token != "" { - src.TranscodingSubProtocol = "http" - src.TranscodingUrl = "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) - } - api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)}) -} - -// streamAudio serves /Audio/{itemId}/stream[.container], reusing the same transcode-decision + -// streaming pipeline as the Subsonic /stream endpoint. -func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - p := req.Params(r) - format := p.StringOr("container", "") - if format == "" { - // The /stream.{container} route form carries the format as a path segment, not a query param. - format = chi.URLParam(r, "container") - } - if format == "" { - // Jellyfin's audioCodec param names the target codec when no container is given. - format = p.StringOr("audiocodec", "") - } - api.serveAudio(w, r, mf, format) -} - -// streamUniversal serves /Audio/{itemId}/universal, where Container lists the "container|codec" -// entries the client direct-plays, and TranscodingContainer/AudioCodec name the fallback target. -func (api *Router) streamUniversal(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - p := req.Params(r) - var streamReq stream.Request - if p.BoolOr("static", false) { - streamReq = api.transcodeDecider.ResolveRequest(r.Context(), mf, "raw", 0, 0) - } else { - streamReq = api.transcodeDecider.ResolveClientRequest(r.Context(), mf, universalClientInfo(p), 0) - } - api.serveStream(w, r, mf, streamReq) -} - -func universalClientInfo(p *req.Values) *stream.ClientInfo { - ci := &stream.ClientInfo{Name: "jellyfin-universal", MaxAudioBitrate: bitRateParam(p)} - for entry := range strings.SplitSeq(p.StringOr("container", ""), ",") { - container, codec, _ := strings.Cut(strings.TrimSpace(entry), "|") - if container == "" { - continue - } - profile := stream.DirectPlayProfile{Containers: []string{container}, Protocols: []string{stream.ProtocolHTTP}} - if codec != "" { - profile.AudioCodecs = []string{codec} - } - ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, profile) - } - codec := p.StringOr("audiocodec", "") - if container := cmp.Or(p.StringOr("transcodingcontainer", ""), codec); container != "" { - ci.TranscodingProfiles = []stream.Profile{{Container: container, AudioCodec: cmp.Or(codec, container), Protocol: stream.ProtocolHTTP}} - ci.MaxTranscodingAudioBitrate = ci.MaxAudioBitrate - } - return ci -} - -// bitRateParam reads Jellyfin's bits/sec bitrate params as the kbps the stream package expects. -func bitRateParam(p *req.Values) int { - return cmp.Or(p.IntOr("audiobitrate", 0), p.IntOr("maxstreamingbitrate", 0)) / 1000 -} - -func (api *Router) serveAudio(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, format string) { - if req.Params(r).BoolOr("static", false) { - format = "raw" - } - api.serveStream(w, r, mf, api.transcodeDecider.ResolveRequest(r.Context(), mf, format, bitRateParam(req.Params(r)), 0)) -} - -func (api *Router) serveStream(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, streamReq stream.Request) { - ctx := r.Context() - // A probe must not start a transcode. Omitting Content-Length is what tells a client (Fintunes) - // this is not direct play; direct play falls through to Serve, which answers HEAD itself. - if r.Method == http.MethodHead && streamReq.Format != "" && streamReq.Format != "raw" { - w.Header().Set("Content-Type", mime.TypeByExtension("."+streamReq.Format)) - w.Header().Set("Accept-Ranges", "none") - w.WriteHeader(http.StatusOK) - return - } - s, err := api.streamer.NewStream(ctx, mf, streamReq) - if err != nil { - api.internalError(w, r, err) - return - } - defer s.Close() - if _, err := s.Serve(ctx, w, r); err != nil { - log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) - } -} - -// streamHls serves /Audio/{itemId}/main.m3u8 (Finamp's transcoding mode) as a single-segment VOD -// playlist whose one segment is the progressive transcode endpoint, reusing that whole pipeline. -// Trade-off: seeking re-reads from the start, like Subsonic transcoded streams. -func (api *Router) streamHls(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - p := req.Params(r) - - // HLS packed audio can only carry ADTS/AAC or MP3; other codecs fall back to aac. A forced - // transcoding wins verbatim — its override rewrites the segment anyway, and the playlist must match. - codec := strings.ToLower(p.StringOr("audiocodec", "")) - if codec != "mp3" { - codec = "aac" - } - if trc, ok := request.TranscodingFrom(r.Context()); ok && trc.TargetFormat != "" { - codec = strings.ToLower(trc.TargetFormat) - } - - // Relative to the playlist URL. HLS fetches drop auth headers, so the token rides in the query. - segment := "stream." + codec - q := url.Values{} - if token := tokenFromRequest(r); token != "" { - q.Set("api_key", token) - } - if bitRate := p.IntOr("audiobitrate", 0); bitRate > 0 { - q.Set("audioBitRate", strconv.Itoa(bitRate)) - } - if len(q) > 0 { - segment += "?" + q.Encode() - } - - w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") - //nolint:gosec // not HTML; the only tainted value is query-escaped - fmt.Fprintf(w, "#EXTM3U\n"+ - "#EXT-X-VERSION:3\n"+ - "#EXT-X-PLAYLIST-TYPE:VOD\n"+ - "#EXT-X-TARGETDURATION:%d\n"+ - "#EXT-X-MEDIA-SEQUENCE:0\n"+ - "#EXTINF:%.3f,\n"+ - "%s\n"+ - "#EXT-X-ENDLIST\n", - int(math.Ceil(float64(mf.Duration))), mf.Duration, segment) -} - -// streamFile serves /Items/{itemId}/File and /Download, Jellyfin's direct-file endpoints. Some -// clients (Finamp's just_audio engine) fetch playback audio here instead of /Audio/{id}/stream, so -// it must always resolve to direct play ("raw"), never a forced transcode. -func (api *Router) streamFile(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - api.serveStream(w, r, mf, api.transcodeDecider.ResolveRequest(r.Context(), mf, "raw", 0, 0)) -} diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go deleted file mode 100644 index 7a1112f37..000000000 --- a/server/jellyfin/stream_test.go +++ /dev/null @@ -1,479 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Stream", func() { - var api *Router - var ds *tests.MockDataStore - var streamer *fakeMediaStreamer - var decider *fakeTranscodeDecider - - // alice has access to library 1 only. - ctxUser := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) - } - - BeforeEach(func() { - ds = &tests.MockDataStore{} - streamer = &fakeMediaStreamer{} - decider = &fakeTranscodeDecider{} - api = &Router{ - ds: ds, streamer: streamer, transcodeDecider: decider, - lyrics: &fakeLyricsService{lyrics: map[string]model.LyricList{}}, - lyricsCache: newTestLyricsCache(), - } - }) - - Describe("getPlaybackInfo", func() { - It("returns a media source for an accessible track", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.getPlaybackInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.PlaybackInfoResponse - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.MediaSources).To(HaveLen(1)) - Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - Expect(res.MediaSources[0].Container).To(Equal("mp3")) - Expect(res.MediaSources[0].Size).To(Equal(int64(1000))) - Expect(res.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$")) - }) - - It("returns 404 for a track in a library the user can't access", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.getPlaybackInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 when the id doesn't match any media file", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/missing/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) - api.getPlaybackInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - playbackInfo := func() dto.PlaybackInfoResponse { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.getPlaybackInfo(w, r) - var res dto.PlaybackInfoResponse - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - return res - } - - lyricStreams := func(res dto.PlaybackInfoResponse) []dto.MediaStream { - var out []dto.MediaStream - for _, s := range res.MediaSources[0].MediaStreams { - if s.Type == "Lyric" { - out = append(out, s) - } - } - return out - } - - It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, - }} - - Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) - }) - - It("advertises no Lyric stream when the pipeline finds nothing", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - - Expect(lyricStreams(playbackInfo())).To(BeEmpty()) - }) - - It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - testID("s1"): {{Kind: "main", Lang: "eng"}}, - }} - - Expect(lyricStreams(playbackInfo())).To(BeEmpty()) - }) - - It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, - }) - api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, - }} - - Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) - }) - - It("still returns 200 with a valid MediaSource and no Lyric stream when the lyrics pipeline errors", func() { - // Own ID: an erroring loader isn't cached, but a shared ID could still pick up - // another test's cached (non-error) result and mask this assertion. - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s-err"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, - }) - api.lyrics = &fakeLyricsService{err: errors.New("boom")} - - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s-err"))+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s-err"))) - api.getPlaybackInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.PlaybackInfoResponse - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.MediaSources).To(HaveLen(1)) - Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID(testID("s-err")))) - Expect(lyricStreams(res)).To(BeEmpty()) - }) - }) - - Describe("streamAudio", func() { - It("invokes the transcode decider and streamer for an accessible track", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - streamer.content = "audio-bytes" - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(decider.invoked).To(BeTrue()) - Expect(streamer.invoked).To(BeTrue()) - Expect(w.Body.String()).To(Equal("audio-bytes")) - }) - - It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(decider.invoked).To(BeFalse()) - Expect(streamer.invoked).To(BeFalse()) - }) - - It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/missing/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) - invoke(api.streamAudio, w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(decider.invoked).To(BeFalse()) - Expect(streamer.invoked).To(BeFalse()) - }) - - It("converts the bps audioBitRate param to kbps", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiobitrate=320000", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - - Expect(decider.req.BitRate).To(Equal(320)) - }) - - It("uses the audioCodec param as target format when no container is given", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiocodec=aac", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - - Expect(decider.req.Format).To(Equal("aac")) - }) - - It("returns 500 and logs when the streamer fails", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - streamer.err = errors.New("boom") - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Describe("HEAD requests", func() { - head := func(query string) *httptest.ResponseRecorder { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, - }) - streamer.content = "audio-bytes" - w := httptest.NewRecorder() - r := httptest.NewRequest("HEAD", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?"+query, nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - return w - } - - It("answers a transcode with the target type and no length, without starting it", func() { - w := head("audioCodec=mp3") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("audio/mpeg")) - Expect(w.Header().Get("Content-Length")).To(BeEmpty()) - Expect(w.Body.String()).To(BeEmpty()) - Expect(streamer.invoked).To(BeFalse()) - }) - - It("answers direct play through the streamer, without a body", func() { - w := head("static=true") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamer.invoked).To(BeTrue()) - Expect(w.Body.String()).To(BeEmpty()) - }) - }) - - Describe("streamUniversal", func() { - universal := func(query string) { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Suffix: "mp3", LibraryID: 1}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/universal?"+query, nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamUniversal, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - } - - It("turns Container into direct play profiles and TranscodingContainer into the target", func() { - universal("Container=mp3,m4a|aac&TranscodingContainer=m4a&AudioCodec=aac&MaxStreamingBitrate=128000") - Expect(decider.client.DirectPlayProfiles).To(Equal([]stream.DirectPlayProfile{ - {Containers: []string{"mp3"}, Protocols: []string{stream.ProtocolHTTP}}, - {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{stream.ProtocolHTTP}}, - })) - Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{ - {Container: "m4a", AudioCodec: "aac", Protocol: stream.ProtocolHTTP}, - })) - Expect(decider.client.MaxAudioBitrate).To(Equal(128)) - Expect(decider.client.MaxTranscodingAudioBitrate).To(Equal(128)) - }) - - It("uses AudioCodec as the target when no TranscodingContainer is given", func() { - universal("Container=mp3&AudioCodec=aac") - Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{ - {Container: "aac", AudioCodec: "aac", Protocol: stream.ProtocolHTTP}, - })) - }) - - It("serves the file as is for static=true", func() { - universal("static=true&Container=ogg") - Expect(decider.req.Format).To(Equal("raw")) - Expect(decider.client).To(BeNil()) - }) - }) - - Describe("streamHls", func() { - BeforeEach(func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1}, - }) - }) - - hls := func(query string, ctx context.Context) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/main.m3u8"+query, nil).WithContext(ctx) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamHls, w, r) - return w - } - - It("returns a single-segment VOD playlist pointing at the progressive stream endpoint", func() { - w := hls("?audiocodec=aac&audiobitrate=320000&api_key=tok", ctxUser()) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) - body := w.Body.String() - Expect(body).To(HavePrefix("#EXTM3U\n")) - Expect(body).To(ContainSubstring("#EXT-X-PLAYLIST-TYPE:VOD\n")) - Expect(body).To(ContainSubstring("#EXT-X-TARGETDURATION:101\n")) - Expect(body).To(ContainSubstring("#EXTINF:100.500,\n")) - Expect(body).To(ContainSubstring("\nstream.aac?api_key=tok&audioBitRate=320000\n")) - Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) - }) - - It("omits the bitrate param when the client doesn't send one", func() { - w := hls("?audiocodec=aac&api_key=tok", ctxUser()) - Expect(w.Body.String()).To(ContainSubstring("\nstream.aac?api_key=tok\n")) - }) - - It("falls back to aac for codecs HLS packed-audio can't carry", func() { - w := hls("?audiocodec=opus", ctxUser()) - Expect(w.Body.String()).To(ContainSubstring("\nstream.aac\n")) - }) - - It("honors mp3 as segment codec", func() { - w := hls("?audiocodec=mp3", ctxUser()) - Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) - }) - - It("prefers the server-forced transcoding format over the requested codec", func() { - ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "mp3"}) - w := hls("?audiocodec=aac", ctx) - Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) - }) - - It("advertises an HLS-incompatible forced format verbatim, matching what the segment will contain", func() { - ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "opus"}) - w := hls("?audiocodec=aac", ctx) - Expect(w.Body.String()).To(ContainSubstring("\nstream.opus\n")) - }) - - It("returns 404 for a track in a library the user can't access", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "dsf", LibraryID: 2}, - }) - Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 when the id doesn't match any media file", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{}) - Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("streamFile", func() { - It("invokes the decider with a raw/direct-play request and the streamer for an accessible track", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - streamer.content = "audio-bytes" - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.streamFile(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(decider.invoked).To(BeTrue()) - Expect(decider.req.Format).To(Equal("raw")) - Expect(streamer.invoked).To(BeTrue()) - Expect(w.Body.String()).To(Equal("audio-bytes")) - }) - - It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.streamFile(w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(decider.invoked).To(BeFalse()) - Expect(streamer.invoked).To(BeFalse()) - }) - - It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/missing/File", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) - api.streamFile(w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(decider.invoked).To(BeFalse()) - Expect(streamer.invoked).To(BeFalse()) - }) - }) -}) - -// fakeTranscodeDecider is a local test double for stream.TranscodeDecider: it records whether -// (and how) ResolveRequest was invoked, so tests can assert it's never called on the -// access-denied path, without needing a real transcode decision pipeline. -type fakeTranscodeDecider struct { - invoked bool - req stream.Request - client *stream.ClientInfo -} - -func (f *fakeTranscodeDecider) MakeDecision(context.Context, *model.MediaFile, *stream.ClientInfo, stream.TranscodeOptions) (*stream.TranscodeDecision, error) { - return &stream.TranscodeDecision{}, nil -} - -func (f *fakeTranscodeDecider) CreateTranscodeParams(*stream.TranscodeDecision) (string, error) { - return "", nil -} - -func (f *fakeTranscodeDecider) ResolveRequestFromToken(context.Context, string, *model.MediaFile, int) (stream.Request, error) { - return stream.Request{}, nil -} - -func (f *fakeTranscodeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request { - f.invoked = true - f.req = stream.Request{Format: format, BitRate: bitRate, Offset: offset} - return f.req -} - -func (f *fakeTranscodeDecider) ResolveClientRequest(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, offset int) stream.Request { - f.invoked = true - f.client = ci - f.req = stream.Request{Offset: offset} - return f.req -} - -// fakeMediaStreamer is a local test double for stream.MediaStreamer: it records whether -// NewStream was invoked and, on success, returns a real (non-seekable) *stream.Stream backed -// by an in-memory reader, so streamAudio's call to Stream.Serve exercises real code. -type fakeMediaStreamer struct { - invoked bool - content string - err error -} - -func (f *fakeMediaStreamer) NewStream(_ context.Context, mf *model.MediaFile, _ stream.Request) (*stream.Stream, error) { - f.invoked = true - if f.err != nil { - return nil, f.err - } - return stream.NewStream(mf, mf.Suffix, 0, io.NopCloser(strings.NewReader(f.content))), nil -} diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go deleted file mode 100644 index 2aae4c9c0..000000000 --- a/server/jellyfin/system.go +++ /dev/null @@ -1,155 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/hex" - "errors" - "fmt" - "net" - "net/http" - "net/netip" - "path" - "strings" - "sync" - - "github.com/google/uuid" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients and SDKs gate on -// it (Streamyfin and the Android apps refuse < 10.10), and some parsers need exactly three parts. -const jellyfinVersion = "12.1.0" - -func serverName() string { - if conf.Server.Jellyfin.ServerName != "" { - return conf.Server.Jellyfin.ServerName - } - return fmt.Sprintf("Navidrome %s", consts.Version) -} - -func (api *Router) serverID(ctx context.Context) string { - return resolveServerID(ctx, api.ds, &api.serverIDVal) -} - -// Package-level: the Router and Discovery are separate objects and must not persist different ids. -var serverIDMu sync.Mutex - -// Clients cache ServerId across sessions, so it is persisted. Only a successful read or write is -// cached: a transient failure yields a temporary id and retries on the next call. -func resolveServerID(ctx context.Context, ds model.DataStore, cached *string) string { - serverIDMu.Lock() - defer serverIDMu.Unlock() - if *cached != "" { - return *cached - } - if ds == nil { - *cached = newServerID() - return *cached - } - id, err := ds.Property().Get(ctx, consts.JellyfinServerIDKey) - switch { - case errors.Is(err, model.ErrNotFound): - id = newServerID() - if err := ds.Property().Put(ctx, consts.JellyfinServerIDKey, id); err != nil { - log.Error(ctx, "Jellyfin API: could not persist server id", err) - return id - } - case err != nil: - log.Error(ctx, "Jellyfin API: could not read server id", err) - return newServerID() - } - // Ids persisted before this change are dashed; normalize on read rather than rewriting the DB. - *cached = strings.ReplaceAll(id, "-", "") - return *cached -} - -// newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")). -func newServerID() string { - u := uuid.New() - return hex.EncodeToString(u[:]) -} - -func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { - return dto.PublicSystemInfo{ - LocalAddress: localAddress(r), - ServerName: serverName(), - Version: jellyfinVersion, - ProductName: "Jellyfin Server", - Id: api.serverID(r.Context()), - StartupWizardCompleted: true, - } -} - -// localAddress reconstructs the base URL the client used (scheme/host from the request, honoring -// X-Forwarded-* headers, plus the mount path), advertised as LocalAddress. Jellify adopts it as -// its server base URL; without it its SDK api instance is undefined and sign-in crashes. -func localAddress(r *http.Request) string { - scheme, host := server.ServerAddress(r) - return scheme + "://" + host + path.Join(conf.Server.BasePath, consts.URLPathJellyfinAPI) -} - -func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) { - api.ok(w, r, api.publicInfo(r)) -} - -func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) { - api.ok(w, r, dto.SystemInfo{ - PublicSystemInfo: api.publicInfo(r), - SupportsLibraryMonitor: true, - }) -} - -// ping answers /System/Ping with a bare plain-text server name (not JSON-quoted): Jellyfin's -// server does this and clients parse the raw body. -func (api *Router) ping(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(serverName())) -} - -// getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a -// dual-connection setup's addresses; a missing IsInNetwork reads to it as "not a Jellyfin server". -func (api *Router) getEndpointInfo(w http.ResponseWriter, r *http.Request) { - remote := remoteIP(r) - api.ok(w, r, dto.EndPointInfo{ - IsLocal: isSameMachine(r, remote), - IsInNetwork: isInLocalNetwork(remote), - }) -} - -// isInLocalNetwork mirrors Jellyfin's default LAN set (NetworkManager.UpdateSettings with no -// LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10. -func isInLocalNetwork(ip netip.Addr) bool { - return ip.IsLoopback() || ip.IsPrivate() || (ip.Is6() && ip.IsLinkLocalUnicast()) -} - -// isSameMachine mirrors Jellyfin's HttpContext.IsLocal(): the caller shares the connection's local -// address. The local address is missing in tests and unreliable behind a proxy, so fall back to loopback. -func isSameMachine(r *http.Request, remote netip.Addr) bool { - local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr) - if !ok { - return remote.IsLoopback() - } - return parseIP(local.String()) == remote -} - -// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP. -func remoteIP(r *http.Request) netip.Addr { - return parseIP(r.RemoteAddr) -} - -func parseIP(addr string) netip.Addr { - if h, _, err := net.SplitHostPort(addr); err == nil { - addr = h - } - ip, err := netip.ParseAddr(addr) - if err != nil { - return netip.Addr{} - } - return ip.Unmap() -} diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go deleted file mode 100644 index d339043a7..000000000 --- a/server/jellyfin/system_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "net" - "net/http" - "net/http/httptest" - "sync" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("System", func() { - var api *Router - BeforeEach(func() { api = &Router{} }) - - It("returns public system info without auth", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ServerName = "" - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Info/Public", nil) - api.getPublicSystemInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) - var info dto.PublicSystemInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - Expect(info.Id).ToNot(BeEmpty()) - Expect(info.Version).To(Equal(jellyfinVersion)) - Expect(info.ProductName).To(Equal("Jellyfin Server")) - Expect(info.ServerName).To(HavePrefix("Navidrome")) - }) - - It("returns authenticated system info with the public fields plus library monitor support", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ServerName = "" - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Info", nil) - api.getSystemInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) - var info dto.SystemInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - Expect(info.Id).ToNot(BeEmpty()) - Expect(info.Version).To(Equal(jellyfinVersion)) - Expect(info.ProductName).To(Equal("Jellyfin Server")) - Expect(info.ServerName).To(HavePrefix("Navidrome")) - Expect(info.SupportsLibraryMonitor).To(BeTrue()) - Expect(info.HasPendingRestart).To(BeFalse()) - Expect(info.IsShuttingDown).To(BeFalse()) - }) - - It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() { - DeferCleanup(configtest.SetupConfig()) - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Info/Public", nil) - r.Host = "music.example.com:4599" - api.getPublicSystemInfo(w, r) - - var info dto.PublicSystemInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - // Jellify connecting over HTTP sets its server base URL from LocalAddress; without it the - // SDK `api` is undefined and sign-in crashes. It must include the /jellyfin mount path. - Expect(info.LocalAddress).To(Equal("http://music.example.com:4599/jellyfin")) - }) - - It("responds to ping with the server name as plain text", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ServerName = "" - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Ping", nil) - api.ping(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/plain")) - // Plain text, not a JSON-quoted string: Jellyfin clients expect the bare server name. - Expect(w.Body.String()).To(HavePrefix("Navidrome")) - }) - - DescribeTable("reports the caller's network location on /System/Endpoint", - func(remoteAddr string, isLocal, isInNetwork bool) { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Endpoint", nil) - r.RemoteAddr = remoteAddr - api.getEndpointInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - // Finamp's connection test only probes for the key's presence, so it must always be emitted. - Expect(w.Body.String()).To(ContainSubstring(`"IsInNetwork"`)) - var info dto.EndPointInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - Expect(info.IsLocal).To(Equal(isLocal)) - Expect(info.IsInNetwork).To(Equal(isInNetwork)) - }, - Entry("loopback", "127.0.0.1:12345", true, true), - Entry("IPv6 loopback", "[::1]:12345", true, true), - Entry("LAN address", "192.168.1.20:54321", false, true), - Entry("bare IP, as left by the RealIP middleware", "10.0.0.5", false, true), - Entry("IPv4-mapped IPv6 LAN address", "[::ffff:172.16.0.9]:80", false, true), - Entry("IPv6 link-local", "[fe80::1]:80", false, true), - Entry("IPv6 unique-local", "[fd00::1]:80", false, true), - // Jellyfin's default LAN set omits 169.254.0.0/16, so we do too. - Entry("IPv4 link-local", "169.254.1.1:80", false, false), - Entry("public address", "8.8.8.8:443", false, false), - Entry("unparseable address", "not-an-ip", false, false), - ) - - It("reports IsLocal when the caller shares the connection's local address", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Endpoint", nil) - r.RemoteAddr = "192.168.1.20:54321" - ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, - &net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533}) - api.getEndpointInfo(w, r.WithContext(ctx)) - - var info dto.EndPointInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - Expect(info.IsLocal).To(BeTrue()) - }) - - It("does not report IsLocal for a different host on the same LAN", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Endpoint", nil) - r.RemoteAddr = "192.168.1.99:54321" - ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, - &net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533}) - api.getEndpointInfo(w, r.WithContext(ctx)) - - var info dto.EndPointInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - Expect(info.IsLocal).To(BeFalse()) - Expect(info.IsInNetwork).To(BeTrue()) - }) - - Context("serverID with a real DataStore", func() { - var ctx context.Context - var ds *tests.MockDataStore - - BeforeEach(func() { - ctx = context.Background() - ds = &tests.MockDataStore{} - }) - - It("persists the generated id so it can be read back by another Router sharing the same DataStore", func() { - first := &Router{ds: ds} - id := first.serverID(ctx) - Expect(id).ToNot(BeEmpty()) - - second := &Router{ds: ds} - Expect(second.serverID(ctx)).To(Equal(id)) - }) - - It("resolves one id when a Router and a Discovery race on first boot", func() { - r, d := &Router{ds: ds}, NewDiscovery(ds) - ids := make([]string, 2) - var wg sync.WaitGroup - wg.Go(func() { ids[0] = r.serverID(ctx) }) - wg.Go(func() { ids[1] = d.serverID(ctx) }) - wg.Wait() - Expect(ids[0]).ToNot(BeEmpty()) - Expect(ids[1]).To(Equal(ids[0])) - }) - - It("memoizes the id across repeated calls on the same Router", func() { - r := &Router{ds: ds} - id := r.serverID(ctx) - Expect(r.serverID(ctx)).To(Equal(id)) - Expect(r.serverID(ctx)).To(Equal(id)) - }) - - It("does not overwrite or pin over a stored id when the property read fails transiently", func() { - Expect(ds.Property().Put(ctx, consts.JellyfinServerIDKey, "6ba7b8109dad11d180b400c04fd430c8")).To(Succeed()) - - r := &Router{ds: ds} - props := ds.Property().(*tests.MockedPropertyRepo) - props.Error = errors.New("database is locked") - degraded := r.serverID(ctx) - Expect(degraded).ToNot(BeEmpty()) - Expect(degraded).ToNot(Equal("6ba7b8109dad11d180b400c04fd430c8")) // temporary value, not the (unreadable) stored one - props.Error = nil - - // Once the DB recovers, the stored id is intact and served again. - Expect(r.serverID(ctx)).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) - stored, err := ds.Property().Get(ctx, consts.JellyfinServerIDKey) - Expect(err).ToNot(HaveOccurred()) - Expect(stored).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) - }) - - It("returns Jellyfin's no-dash GUID form", func() { - r := &Router{ds: ds} - Expect(r.serverID(ctx)).To(MatchRegexp("^[0-9a-f]{32}$")) - }) - - It("strips dashes from an already-persisted id", func() { - Expect(ds.Property().Put(ctx, - consts.JellyfinServerIDKey, "1b4e28ba-2fa1-11d2-883f-0016d3cca427")).To(Succeed()) - r := &Router{ds: ds} - Expect(r.serverID(ctx)).To(Equal("1b4e28ba2fa111d2883f0016d3cca427")) - }) - }) -}) diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go deleted file mode 100644 index d0a54ee63..000000000 --- a/server/jellyfin/users.go +++ /dev/null @@ -1,71 +0,0 @@ -package jellyfin - -import ( - "net/http" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -// getUserViews returns one CollectionFolder view per accessible library, so clients browse each -// library as its own top-level view rather than one aggregate. -func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - u, _ := request.UserFrom(ctx) - // u.Libraries comes from a projection without counts or stats, and clients hide a library that - // looks empty, so the rows are re-read in full here. - libs, err := api.ds.Library().GetAll(ctx) - if err != nil { - api.internalError(w, r, err) - return - } - views := make([]dto.BaseItemDto, 0, len(libs)) - for _, lib := range libs { - if u.HasLibraryAccess(lib.ID) { - views = append(views, dto.LibraryToBaseItem(lib)) - } - } - api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0}) -} - -func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - u, _ := request.UserFrom(ctx) - api.ok(w, r, userToDto(&u, serverName(), api.serverID(ctx))) -} - -// getPublicUsers advertises the users named in Jellyfin.ExposedPublicUsers for a client login -// picker. The route is unauthenticated, so it lists only the configured allowlist (never the full -// user table) and returns a minimal DTO — no Policy/Configuration, which would leak admin status. -func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - serverID := api.serverID(ctx) - seen := make(map[string]bool) - users := []dto.UserDto{} - for name := range strings.SplitSeq(conf.Server.Jellyfin.ExposedPublicUsers, ",") { - name = strings.TrimSpace(name) - if name == "" { - continue - } - key := strings.ToLower(name) - if seen[key] { - continue - } - seen[key] = true - usr, err := api.ds.User().FindByUsername(ctx, name) - if err != nil { - log.Warn(ctx, "Jellyfin API: configured public user not found", "username", name, err) - continue - } - users = append(users, dto.UserDto{ - Name: usr.UserName, - Id: dto.EncodeID(usr.ID), - ServerId: serverID, - HasPassword: true, - }) - } - api.ok(w, r, users) -} diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go deleted file mode 100644 index 793a64a29..000000000 --- a/server/jellyfin/users_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "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/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Users", func() { - var ctx context.Context - var api *Router - // The repo holds the full rows; the user carries the id/name-only copy its projection returns. - authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request { - api.ds.Library().(*tests.MockLibraryRepo).SetData(libs) - stripped := make(model.Libraries, len(libs)) - for i, lib := range libs { - stripped[i] = model.Library{ID: lib.ID, Name: lib.Name} - } - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: stripped}) - return r.WithContext(ctx) - } - BeforeEach(func() { - ctx = GinkgoT().Context() - api = &Router{ds: &tests.MockDataStore{}} - }) - - Describe("getUserViews", func() { - It("returns one view per accessible library", func() { - libs := model.Libraries{{ID: 1, Name: "Music"}, {ID: 2, Name: "Podcasts"}} - w := httptest.NewRecorder() - api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - Expect(res.TotalRecordCount).To(Equal(2)) - - Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1))) - Expect(res.Items[0].Name).To(Equal("Music")) - Expect(res.Items[0].Type).To(Equal("CollectionFolder")) - Expect(res.Items[0].CollectionType).To(Equal("music")) - Expect(res.Items[0].IsFolder).To(BeTrue()) - - Expect(res.Items[1].Id).To(Equal(dto.EncodeLibraryID(2))) - Expect(res.Items[1].Name).To(Equal("Podcasts")) - }) - - // Manet keeps no library, and so syncs no artists/albums/tracks, when these are missing. - It("describes the library like Jellyfin's CollectionFolder", func() { - created := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) - libs := model.Libraries{{ID: 1, Name: "Music", Path: "/music", TotalAlbums: 42, CreatedAt: created}} - w := httptest.NewRecorder() - api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - - item := res.Items[0] - Expect(*item.ChildCount).To(Equal(42)) - Expect(item.DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z")) - Expect(item.SortName).To(Equal("Music")) - Expect(item.Path).To(Equal("/music")) - Expect(item.LocationType).To(Equal("FileSystem")) - Expect(item.UserData).ToNot(BeNil()) - Expect(item.UserData.ItemId).To(Equal(dto.EncodeLibraryID(1))) - }) - - It("returns a single view for a user with one library", func() { - libs := model.Libraries{{ID: 1, Name: "Music"}} - w := httptest.NewRecorder() - api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1))) - }) - - It("returns no views for a user with no library access", func() { - w := httptest.NewRecorder() - api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), nil)) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(0)) - Expect(res.TotalRecordCount).To(Equal(0)) - }) - }) - - It("returns the current user", func() { - w := httptest.NewRecorder() - api.getCurrentUser(w, authedWithLibraries(httptest.NewRequest("GET", "/Users/Me", nil), nil)) - var u dto.UserDto - Expect(json.Unmarshal(w.Body.Bytes(), &u)).To(Succeed()) - Expect(u.Name).To(Equal("alice")) - Expect(u.Policy).ToNot(BeNil()) - Expect(u.Policy.IsAdministrator).To(BeFalse()) - Expect(u.Configuration).ToNot(BeNil()) - }) - - Describe("getPublicUsers", func() { - var ur *tests.MockedUserRepo - publicUsers := func() []dto.UserDto { - w := httptest.NewRecorder() - api.getPublicUsers(w, httptest.NewRequest("GET", "/Users/Public", nil)) - Expect(w.Code).To(Equal(http.StatusOK)) - var users []dto.UserDto - Expect(json.Unmarshal(w.Body.Bytes(), &users)).To(Succeed()) - return users - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ur = api.ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice"})).To(Succeed()) - Expect(ur.Put(ctx, &model.User{ID: testID("u2"), UserName: "bob"})).To(Succeed()) - }) - - It("returns an empty list when the config is unset", func() { - conf.Server.Jellyfin.ExposedPublicUsers = "" - Expect(publicUsers()).To(BeEmpty()) - }) - - It("lists the configured users in order, without leaking policy", func() { - conf.Server.Jellyfin.ExposedPublicUsers = "bob, alice" - users := publicUsers() - Expect(users).To(HaveLen(2)) - Expect(users[0].Name).To(Equal("bob")) - Expect(users[0].Id).To(Equal(dto.EncodeID(testID("u2")))) - Expect(users[1].Name).To(Equal("alice")) - // The public list must not expose Policy/Configuration to unauthenticated callers. - Expect(users[0].Policy).To(BeNil()) - Expect(users[0].Configuration).To(BeNil()) - }) - - It("skips a configured username that does not exist", func() { - conf.Server.Jellyfin.ExposedPublicUsers = "alice,ghost" - users := publicUsers() - Expect(users).To(HaveLen(1)) - Expect(users[0].Name).To(Equal("alice")) - }) - - It("matches usernames case-insensitively and de-duplicates", func() { - conf.Server.Jellyfin.ExposedPublicUsers = "ALICE, alice" - users := publicUsers() - Expect(users).To(HaveLen(1)) - Expect(users[0].Name).To(Equal("alice")) - }) - }) -}) diff --git a/server/middlewares.go b/server/middlewares.go index b65a2d6e1..5d6a1e59c 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -7,9 +7,7 @@ import ( "errors" "fmt" "io/fs" - "net" "net/http" - "net/netip" "net/url" "strings" "time" @@ -17,7 +15,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" - "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -168,82 +165,20 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler { }) } -// realIPMiddleware resolves the request's client IP into the context, where it can be read with -// middleware.GetClientIP, and mirrors it into RemoteAddr for logging and player registration. -// Forwarding headers are only honoured when the peer is listed in ExtAuth.TrustedSources, so that -// a client cannot pick its own identity and evade controls keyed on it. The peer address is kept -// in the context as request.ReverseProxyIp. +// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's +// context if navidrome is behind a trusted reverse proxy. func realIPMiddleware(next http.Handler) http.Handler { - trusted := conf.Server.ExtAuth.TrustedSources - fromPeer := middleware.ClientIPFromRemoteAddr(next) - if trusted == "" { - return fromPeer + if conf.Server.ExtAuth.TrustedSources != "" { + return chi.Chain( + reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }), + middleware.RealIP, + ).Handler(next) } - // Last match wins, so this order reproduces RealIP's precedence: True-Client-IP, X-Real-IP, - // X-Forwarded-For, peer. Only X-Forwarded-For is checked against the trusted list. - fromProxy := chi.Chain( - middleware.ClientIPFromRemoteAddr, - middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...), - middleware.ClientIPFromHeader("X-Real-IP"), - middleware.ClientIPFromHeader("True-Client-IP"), - ).Handler(mirrorClientIP(next)) - - dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if validateIPAgainstList(r.RemoteAddr, trusted) { - fromProxy.ServeHTTP(w, r) - return - } - log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr) - fromPeer.ServeHTTP(w, r) - }) - return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch) -} - -// mirrorClientIP copies the resolved client IP into RemoteAddr when it differs from the peer. -func mirrorClientIP(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) { - r.RemoteAddr = ip - } - next.ServeHTTP(w, r) - }) -} - -// peerHost returns the host part of RemoteAddr, which may already be a bare IP. -func peerHost(r *http.Request) string { - if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { - return host - } - return r.RemoteAddr -} - -// trustedProxyPrefixes returns the CIDR entries of a trusted sources list, skipping non-CIDR -// entries such as the "@" unix socket marker. An empty result makes ClientIPFromXFF trust -// exactly one hop. -func trustedProxyPrefixes(list string) []string { - var prefixes []string - for _, entry := range strings.Split(list, ",") { - entry = strings.TrimSpace(entry) - if _, err := netip.ParsePrefix(entry); err == nil { - prefixes = append(prefixes, entry) - } - } - return prefixes -} - -// ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers -// cannot be rotated for a fresh bucket. -func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler { - return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) { - return ClientIP(r), nil - }) -} - -// ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The -// peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket. -func ClientIP(r *http.Request) string { - return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))) + // The middleware is applied without a trusted reverse proxy to support other use-cases such as multiple clients + // behind a caching proxy. In this case, navidrome only uses the request's RemoteAddr for logging, so the security + // impact of reading the headers from untrusted sources is limited. + return middleware.RealIP(next) } // reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key @@ -267,14 +202,12 @@ func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.H func serverAddressMiddleware(h http.Handler) http.Handler { // Define a new handler function that will be returned by this middleware function. fn := func(w http.ResponseWriter, r *http.Request) { - // Call the ServerAddress function to get the scheme and host of the server + // Call the serverAddress function to get the scheme and host of the server // handling the request. If a host is found, modify the request object to use // that host and scheme instead of the original ones. - if rScheme, rHost := ServerAddress(r); rHost != "" { + if rScheme, rHost := serverAddress(r); rHost != "" { r.Host = rHost r.URL.Scheme = rScheme - // Recorded so code running without the request (e.g. plugins) can build public URLs. - r = r.WithContext(request.WithServerAddress(r.Context(), rScheme, rHost)) } // Call the next handler in the chain with the modified request and response. @@ -292,10 +225,10 @@ var ( xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme") ) -// ServerAddress is a helper function that returns the scheme and host of the server +// serverAddress is a helper function that returns the scheme and host of the server // handling the given request, as determined by the presence of X-Forwarded-* headers // or the scheme and host of the request URL. -func ServerAddress(r *http.Request) (scheme, host string) { +func serverAddress(r *http.Request) (scheme, host string) { // Save the original request host for later comparison. origHost := r.Host @@ -380,7 +313,7 @@ func UpdateLastAccessMiddleware(ds model.DataStore) func(next http.Handler) http ctx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() - err := ds.User().UpdateLastAccessAt(ctx, usr.ID) + err := ds.User(ctx).UpdateLastAccessAt(usr.ID) if err != nil { log.Warn(ctx, "Could not update user's lastAccessAt", "username", usr.UserName, "elapsed", time.Since(start), err) diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 15cf70341..5cecba7d5 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -9,12 +9,10 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" @@ -71,15 +69,10 @@ var _ = Describe("middlewares", func() { middleware http.Handler recorder *httptest.ResponseRecorder req *http.Request - gotScheme string - gotHost string - gotOK bool ) BeforeEach(func() { - gotScheme, gotHost, gotOK = "", "", false nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotScheme, gotHost, gotOK = request.ServerAddressFrom(r.Context()) w.WriteHeader(http.StatusOK) }) middleware = serverAddressMiddleware(nextHandler) @@ -96,13 +89,6 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("example.com")) Expect(req.URL.Scheme).To(Equal("http")) }) - - It("should record the address in the context", func() { - middleware.ServeHTTP(recorder, req) - Expect(gotOK).To(BeTrue()) - Expect(gotScheme).To(Equal("http")) - Expect(gotHost).To(Equal("example.com")) - }) }) Context("with X-Forwarded-Host header", func() { @@ -157,22 +143,6 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("forwarded.example.com")) Expect(req.URL.Scheme).To(Equal("https")) }) - - It("should record the forwarded address in the context", func() { - middleware.ServeHTTP(recorder, req) - Expect(gotOK).To(BeTrue()) - Expect(gotScheme).To(Equal("https")) - Expect(gotHost).To(Equal("forwarded.example.com")) - }) - - It("lets a handler build a public URL on the forwarded address", func() { - var got string - serverAddressMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got = publicurl.AbsoluteURL(r.Context(), "/share/img/token", nil) - })).ServeHTTP(recorder, req) - - Expect(got).To(Equal("https://forwarded.example.com/share/img/token")) - }) }) Context("with multiple values in X-Forwarded-Host header", func() { @@ -381,7 +351,7 @@ var _ = Describe("middlewares", func() { id = uuid.NewString() ds = &tests.MockDataStore{} lastAccessTime = time.Now() - Expect(ds.User().Put(ctx, &model.User{ID: id, UserName: "johndoe", LastAccessAt: &lastAccessTime})). + Expect(ds.User(ctx).Put(&model.User{ID: id, UserName: "johndoe", LastAccessAt: &lastAccessTime})). To(Succeed()) middleware = UpdateLastAccessMiddleware(ds) @@ -407,14 +377,14 @@ var _ = Describe("middlewares", func() { callMiddleware(req) - user, _ := ds.MockedUser.FindByUsername(ctx, "johndoe") + user, _ := ds.MockedUser.FindByUsername("johndoe") Expect(*user.LastAccessAt).To(BeTemporally(">", lastAccessTime, time.Second)) }) It("skip fast successive requests", func() { // First request callMiddleware(req) - user, _ := ds.MockedUser.FindByUsername(ctx, "johndoe") + user, _ := ds.MockedUser.FindByUsername("johndoe") lastAccessTime = *user.LastAccessAt // Store the last access time // Second request @@ -422,7 +392,7 @@ var _ = Describe("middlewares", func() { callMiddleware(req) // The second request should not have changed the last access time - user, _ = ds.MockedUser.FindByUsername(ctx, "johndoe") + user, _ = ds.MockedUser.FindByUsername("johndoe") Expect(user.LastAccessAt).To(Equal(&lastAccessTime)) }) }) @@ -431,105 +401,9 @@ var _ = Describe("middlewares", func() { req = req.WithContext(context.Background()) callMiddleware(req) - usr, _ := ds.MockedUser.FindByUsername(ctx, "johndoe") + usr, _ := ds.MockedUser.FindByUsername("johndoe") Expect(usr.LastAccessAt).To(Equal(&lastAccessTime)) }) }) }) - Describe("realIPMiddleware", func() { - var resolved, remoteAddr string - var proxyIP any - next := func(w http.ResponseWriter, r *http.Request) { - resolved = middleware.GetClientIP(r.Context()) - remoteAddr = r.RemoteAddr - proxyIP = r.Context().Value(request.ReverseProxyIp) - } - call := func(peer string, headers map[string]string) { - resolved, remoteAddr, proxyIP = "", "", nil - r := httptest.NewRequest("POST", "/auth/login", nil) - r.RemoteAddr = peer - for k, v := range headers { - r.Header.Set(k, v) - } - realIPMiddleware(http.HandlerFunc(next)).ServeHTTP(httptest.NewRecorder(), r) - } - - Context("without a trusted proxy", func() { - It("ignores client-supplied forwarding headers", func() { - call("10.0.0.1:1234", map[string]string{ - "X-Forwarded-For": "203.0.113.5", - "X-Real-IP": "203.0.113.6", - "True-Client-IP": "203.0.113.7", - }) - Expect(resolved).To(Equal("10.0.0.1")) - }) - It("leaves RemoteAddr untouched", func() { - call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) - Expect(remoteAddr).To(Equal("10.0.0.1:1234")) - }) - }) - - Context("with a trusted proxy", func() { - BeforeEach(func() { - conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" - }) - It("uses the forwarded client IP when the peer is a trusted proxy", func() { - call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5, 10.0.0.1"}) - Expect(resolved).To(Equal("203.0.113.5")) - Expect(remoteAddr).To(Equal("203.0.113.5")) - }) - It("honours X-Real-IP from a trusted proxy", func() { - call("10.0.0.1:1234", map[string]string{"X-Real-IP": "203.0.113.6"}) - Expect(resolved).To(Equal("203.0.113.6")) - }) - It("ignores forwarding headers when the peer is not a trusted proxy", func() { - call("198.51.100.9:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) - Expect(resolved).To(Equal("198.51.100.9")) - Expect(remoteAddr).To(Equal("198.51.100.9:1234")) - }) - It("keeps the peer address in the context for external auth", func() { - call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) - Expect(proxyIP).To(Equal("10.0.0.1:1234")) - }) - }) - }) - - Describe("ClientIPRateLimiter", func() { - var handler http.Handler - JustBeforeEach(func() { - handler = realIPMiddleware(ClientIPRateLimiter(2, time.Minute)( - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))) - }) - attempt := func(peer string, header, value string) int { - r := httptest.NewRequest("POST", "/auth/login", nil) - r.RemoteAddr = peer - r.Header.Set(header, value) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - return w.Code - } - - DescribeTable("keeps one bucket per peer when the forwarding header is rotated", - func(header string) { - Expect(attempt("198.51.100.9:1", header, "203.0.113.1")).To(Equal(http.StatusOK)) - Expect(attempt("198.51.100.9:2", header, "203.0.113.2")).To(Equal(http.StatusOK)) - Expect(attempt("198.51.100.9:3", header, "203.0.113.3")).To(Equal(http.StatusTooManyRequests)) - }, - Entry("X-Forwarded-For", "X-Forwarded-For"), - Entry("X-Real-IP", "X-Real-IP"), - Entry("True-Client-IP", "True-Client-IP"), - ) - - Context("behind a trusted proxy", func() { - BeforeEach(func() { - conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" - }) - It("gives each real client its own bucket", func() { - Expect(attempt("10.0.0.1:1", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK)) - Expect(attempt("10.0.0.1:2", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK)) - Expect(attempt("10.0.0.1:3", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusTooManyRequests)) - Expect(attempt("10.0.0.1:4", "X-Forwarded-For", "203.0.113.2")).To(Equal(http.StatusOK)) - }) - }) - }) }) diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go index 91508c825..1b78bb93e 100644 --- a/server/nativeapi/artists.go +++ b/server/nativeapi/artists.go @@ -15,12 +15,14 @@ import ( ) func (api *Router) addArtistRoute(r chi.Router) { - repo := api.ds.Artist() + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.Artist{}) + } r.Route("/artist", func(r chi.Router) { - r.Get("/", rest.GetAll(repo)) + r.Get("/", rest.GetAll(constructor)) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(repo)) + r.Get("/", rest.Get(constructor)) r.Post("/image", api.uploadArtistImage()) r.Delete("/image", api.deleteArtistImage()) }) @@ -30,7 +32,7 @@ func (api *Router) addArtistRoute(r chi.Router) { func (api *Router) uploadArtistImage() http.HandlerFunc { return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { artistID := chi.URLParamFromCtx(ctx, "id") - ar, err := api.ds.Artist().Get(ctx, artistID) + ar, err := api.ds.Artist(ctx).Get(artistID) if err != nil { if errors.Is(err, model.ErrNotFound) { return model.ErrNotFound @@ -43,19 +45,16 @@ func (api *Router) uploadArtistImage() http.HandlerFunc { return err } ar.UploadedImage = filename - ar.UpdatedAt = new(time.Now()) - if err := api.ds.Artist().Put(ctx, ar, "uploaded_image", "updated_at"); err != nil { - return err - } - api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) - return nil + now := time.Now() + ar.UpdatedAt = &now + return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") }) } func (api *Router) deleteArtistImage() http.HandlerFunc { return handleImageDelete(func(ctx context.Context) error { artistID := chi.URLParamFromCtx(ctx, "id") - ar, err := api.ds.Artist().Get(ctx, artistID) + ar, err := api.ds.Artist(ctx).Get(artistID) if err != nil { if errors.Is(err, model.ErrNotFound) { return model.ErrNotFound @@ -66,11 +65,8 @@ func (api *Router) deleteArtistImage() http.HandlerFunc { return err } ar.UploadedImage = "" - ar.UpdatedAt = new(time.Now()) - if err := api.ds.Artist().Put(ctx, ar, "uploaded_image", "updated_at"); err != nil { - return err - } - api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) - return nil + now := time.Now() + ar.UpdatedAt = &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 cfecfa663..02626a4ee 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 6ac41f07a..4e6e9e89b 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -11,7 +11,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/tests" @@ -20,19 +19,16 @@ import ( ) var _ = Describe("Config API", func() { - var ctx context.Context var ds model.DataStore var router http.Handler var adminUser, regularUser model.User BeforeEach(func() { - ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users @@ -52,8 +48,8 @@ var _ = Describe("Config API", func() { } // Store in mock datastore - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) - Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) }) Describe("GET /api/config", func() { diff --git a/server/nativeapi/delete_many_response_test.go b/server/nativeapi/delete_many_response_test.go deleted file mode 100644 index 7d911d6cd..000000000 --- a/server/nativeapi/delete_many_response_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package nativeapi - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("writeDeleteManyResponse", func() { - var w *httptest.ResponseRecorder - - write := func(ids ...string) map[string]any { - w = httptest.NewRecorder() - writeDeleteManyResponse(w, httptest.NewRequest("DELETE", "/missing", nil), ids) - - var body map[string]any - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed(), "response body must be valid JSON: %s", w.Body.String()) - return body - } - - It("returns a single id as an object", func() { - Expect(write("abc123")).To(HaveKeyWithValue("id", "abc123")) - }) - - It("returns multiple ids as a list", func() { - Expect(write("a", "b")).To(HaveKeyWithValue("ids", ConsistOf("a", "b"))) - }) - - It("stays valid JSON when the id contains a backslash", func() { - Expect(write(`a\`)).To(HaveKeyWithValue("id", `a\`)) - }) - - It("stays valid JSON when the id contains a quote", func() { - Expect(write(`a"b`)).To(HaveKeyWithValue("id", `a"b`)) - }) - - It("does not HTML-escape the id into entities", func() { - Expect(write("a&b")).To(HaveKeyWithValue("id", "a&b")) - }) - - It("responds 200 with a JSON content type", func() { - write("abc123") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - }) -}) diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index c4ff62e42..5e2d29876 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -13,14 +13,23 @@ import ( "path/filepath" "strings" + "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" _ "golang.org/x/image/webp" ) +func maxImageUploadSize() int64 { + if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { + return int64(size) + } + size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) + return int64(size) +} + func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { user, _ := request.UserFrom(r.Context()) if !conf.Server.EnableArtworkUpload && !user.IsAdmin { @@ -31,7 +40,7 @@ func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { } func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { - maxImageSize := artwork.MaxImageUploadSize() + maxImageSize := maxImageUploadSize() return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !checkImageUploadPermission(w, r) { diff --git a/server/nativeapi/image_upload_test.go b/server/nativeapi/image_upload_test.go new file mode 100644 index 000000000..291912e67 --- /dev/null +++ b/server/nativeapi/image_upload_test.go @@ -0,0 +1,34 @@ +package nativeapi + +import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("maxImageUploadSize", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("returns the configured size when valid", func() { + conf.Server.MaxImageUploadSize = "20MB" + Expect(maxImageUploadSize()).To(Equal(int64(20_000_000))) + }) + + It("returns the default size when config is empty", func() { + conf.Server.MaxImageUploadSize = "" + Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("returns the default size when config is invalid", func() { + conf.Server.MaxImageUploadSize = "not-a-size" + Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("parses raw byte values", func() { + conf.Server.MaxImageUploadSize = "52428800" + Expect(maxImageUploadSize()).To(Equal(int64(52_428_800))) + }) +}) diff --git a/server/nativeapi/inspect.go b/server/nativeapi/inspect.go index 61013dd5a..7c96312ed 100644 --- a/server/nativeapi/inspect.go +++ b/server/nativeapi/inspect.go @@ -13,7 +13,7 @@ import ( ) func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.InspectOutput, error) { - file, err := ds.MediaFile().Get(ctx, id) + file, err := ds.MediaFile(ctx).Get(id) if err != nil { return nil, err } @@ -22,12 +22,7 @@ func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.Inspec return nil, model.ErrNotFound } - lib, err := ds.Library().Get(ctx, file.LibraryID) - if err != nil { - return nil, err - } - - return core.Inspect(file.AbsolutePath(), *lib, file.FolderID) + return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID) } func inspect(ds model.DataStore) http.HandlerFunc { diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index cc05a30e5..ed5564a41 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -9,11 +9,9 @@ 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" - "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/tests" @@ -22,19 +20,16 @@ import ( ) var _ = Describe("Library API", func() { - var ctx context.Context var ds model.DataStore var router http.Handler var adminUser, regularUser model.User var library1, library2 model.Library BeforeEach(func() { - ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users @@ -66,10 +61,10 @@ var _ = Describe("Library API", func() { } // Store in mock datastore - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) - Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) - Expect(ds.Library().Put(ctx, &library1)).To(Succeed()) - Expect(ds.Library().Put(ctx, &library2)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) + Expect(ds.Library(context.TODO()).Put(&library1)).To(Succeed()) + Expect(ds.Library(context.TODO()).Put(&library2)).To(Succeed()) }) Describe("Library CRUD Operations", func() { @@ -296,7 +291,7 @@ var _ = Describe("Library API", func() { Describe("GET /api/user/{id}/library", func() { It("returns user's libraries", func() { // Set up user libraries - err := ds.User().SetUserLibraries(ctx, regularUser.ID, []int{1, 2}) + err := ds.User(context.TODO()).SetUserLibraries(regularUser.ID, []int{1, 2}) Expect(err).ToNot(HaveOccurred()) req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, adminToken) diff --git a/server/nativeapi/metadata.go b/server/nativeapi/metadata.go deleted file mode 100644 index 913ab0471..000000000 --- a/server/nativeapi/metadata.go +++ /dev/null @@ -1,56 +0,0 @@ -package nativeapi - -import ( - "context" - "errors" - "net/http" - "slices" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -func (api *Router) addMetadataRoute(r chi.Router) { - r.Post("/metadata/{kind}/{id}/refresh", api.refreshMetadata()) -} - -// refreshMetadata clears the artwork state deliberately, so a wrong pick cannot be served from -// cache again; reads fall back to local resolution while the worker re-runs the chain at Bump. -func (api *Router) refreshMetadata() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - kind, _ := model.ParseKind(chi.URLParam(r, "kind")) - id := chi.URLParam(r, "id") - if !slices.Contains(artwork.RefreshableKinds, kind) { - http.Error(w, "invalid artwork kind", http.StatusBadRequest) - return - } - if _, err := artwork.ItemName(ctx, api.ds, kind, id); err != nil { - if errors.Is(err, model.ErrNotFound) { - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - } - log.Error(ctx, "Error looking up item to refresh", "kind", kind, "id", id, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - if err := artwork.Refresh(ctx, api.ds, kind, id); err != nil { - log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - if external.HasInfo(kind) { - // Detached: the request context is cancelled the moment this handler returns 204. - bg := context.WithoutCancel(ctx) - go func() { - if err := api.provider.RefreshInfo(bg, kind, id); err != nil { - log.Error(bg, "Error refreshing external info", "kind", kind, "id", id, err) - } - }() - } - w.WriteHeader(http.StatusNoContent) - } -} diff --git a/server/nativeapi/metadata_test.go b/server/nativeapi/metadata_test.go deleted file mode 100644 index 294a26efe..000000000 --- a/server/nativeapi/metadata_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package nativeapi - -import ( - "context" - "net/http" - "net/http/httptest" - "slices" - "sync" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type fakeProvider struct { - external.Provider - mu sync.Mutex - called []string -} - -func (f *fakeProvider) RefreshInfo(_ context.Context, kind model.Kind, id string) error { - f.mu.Lock() - defer f.mu.Unlock() - f.called = append(f.called, kind.Prefix()+"/"+id) - return nil -} - -func (f *fakeProvider) calls() []string { - f.mu.Lock() - defer f.mu.Unlock() - return slices.Clone(f.called) -} - -var _ = Describe("Metadata API", func() { - var ctx context.Context - var ds *tests.MockDataStore - var artRepo *tests.MockArtworkRepo - var queueRepo *tests.MockArtworkQueueRepo - var albumRepo *tests.MockAlbumRepo - var provider *fakeProvider - var router http.Handler - var adminToken, userToken string - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - albumRepo = tests.CreateMockAlbumRepo() - artistRepo := tests.CreateMockArtistRepo() - playlistRepo := tests.CreateMockPlaylistRepo() - Expect(albumRepo.Put(ctx, &model.Album{ID: "al-1", Name: "Kid A"})).To(Succeed()) - Expect(artistRepo.Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) - Expect(playlistRepo.Put(ctx, &model.Playlist{ID: "pl-1", Name: "My Playlist"})).To(Succeed()) - ds = &tests.MockDataStore{ - MockedArtwork: artRepo, - MockedArtworkQueue: queueRepo, - MockedAlbum: albumRepo, - MockedArtist: artistRepo, - MockedPlaylist: playlistRepo, - } - auth.Init(ds) - provider = &fakeProvider{} - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil) - router = server.JWTVerifier(nativeRouter) - - adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} - regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) - Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) - - var err error - adminToken, err = auth.CreateToken(&adminUser) - Expect(err).ToNot(HaveOccurred()) - userToken, err = auth.CreateToken(®ularUser) - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("POST /api/metadata/{kind}/{id}/refresh", func() { - It("clears state and enqueues a Bump for admins", func() { - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external", - })).To(Succeed()) - - req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - - queued, err := queueRepo.DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "al"), - HaveField("ItemID", "al-1"), - HaveField("Priority", model.ArtworkPriorityBump), - ))) - }) - - It("returns 400 for an invalid kind", func() { - req := createAuthenticatedRequest("POST", "/metadata/xx/id-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("denies access to regular users", func() { - req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, userToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("denies access without authentication", func() { - req := createUnauthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("triggers an external info refresh for albums", func() { - req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Eventually(provider.calls).Should(ContainElement("al/al-1")) - }) - - It("triggers an external info refresh for artists", func() { - req := createAuthenticatedRequest("POST", "/metadata/ar/ar-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Eventually(provider.calls).Should(ContainElement("ar/ar-1")) - }) - - It("skips the external info refresh for kinds without external info", func() { - req := createAuthenticatedRequest("POST", "/metadata/pl/pl-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Consistently(provider.calls).ShouldNot(ContainElement("pl/pl-1")) - }) - - It("returns 404 for an unknown id", func() { - req := createAuthenticatedRequest("POST", "/metadata/al/nope/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 500 when the lookup fails for a reason other than not-found", func() { - albumRepo.SetError(true) - - req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) -}) diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index a906e36b9..2b455e622 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -6,6 +6,7 @@ import ( "maps" "net/http" + "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" @@ -14,21 +15,24 @@ import ( ) type missingRepository struct { - rest.Repository[model.MediaFile] + model.ResourceRepository mfRepo model.MediaFileRepository } -func newMissingRepository(ds model.DataStore) rest.Repository[model.MediaFile] { - mf := ds.MediaFile() - return &missingRepository{Repository: mf, mfRepo: mf} +func newMissingRepository(ds model.DataStore) rest.RepositoryConstructor { + return func(ctx context.Context) rest.Repository { + return &missingRepository{mfRepo: ds.MediaFile(ctx), ResourceRepository: ds.Resource(ctx, model.MediaFile{})} + } } -func (r *missingRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.Repository.Count(ctx, r.parseOptions(options)) +func (r *missingRepository) Count(options ...rest.QueryOptions) (int64, error) { + opt := r.parseOptions(options) + return r.ResourceRepository.Count(opt) } -func (r *missingRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.MediaFile, error) { - return r.Repository.ReadAll(ctx, r.parseOptions(options)) +func (r *missingRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + opt := r.parseOptions(options) + return r.ResourceRepository.ReadAll(opt) } func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.QueryOptions { @@ -41,15 +45,22 @@ func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.Query return opt } -func (r *missingRepository) Read(ctx context.Context, id string) (*model.MediaFile, error) { - mf, err := r.mfRepo.Get(ctx, id) +func (r *missingRepository) Read(id string) (any, error) { + all, err := r.mfRepo.GetAll(model.QueryOptions{Filters: squirrel.And{ + squirrel.Eq{"id": id}, + squirrel.Eq{"missing": true}, + }}) if err != nil { return nil, err } - if !mf.Missing { + if len(all) == 0 { return nil, model.ErrNotFound } - return mf, nil + return all[0], nil +} + +func (r *missingRepository) EntityName() string { + return "missing_files" } func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc { @@ -57,7 +68,7 @@ func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc { ctx := r.Context() p := req.Params(r) - ids := p.Strings("id") + ids, _ := p.Strings("id") var err error if len(ids) == 0 { @@ -79,3 +90,5 @@ func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc { writeDeleteManyResponse(w, r, ids) } } + +var _ model.ResourceRepository = &missingRepository{} diff --git a/server/nativeapi/missing_test.go b/server/nativeapi/missing_test.go deleted file mode 100644 index a53f13fc3..000000000 --- a/server/nativeapi/missing_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package nativeapi - -import ( - "bytes" - "net/http" - "net/http/httptest" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Missing Files Endpoint", func() { - var router http.Handler - var token string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SessionTimeout = time.Minute - conf.Server.EnableSharing = false - - mfRepo := tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{ - {ID: "missing-1", Title: "Gone", Missing: true}, - {ID: "present-1", Title: "Here"}, - }) - userRepo := tests.CreateMockUserRepo() - ds := &tests.MockDataStore{MockedMediaFile: mfRepo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}} - auth.Init(ds) - - user := model.User{ID: "user-1", UserName: "user", NewPassword: "pass"} - Expect(userRepo.Put(GinkgoT().Context(), &user)).To(Succeed()) - var err error - token, err = auth.CreateToken(&user) - Expect(err).ToNot(HaveOccurred()) - - router = server.JWTVerifier(New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)) - }) - - DescribeTable("GET /missing/{id}", - func(id string, status int) { - w := httptest.NewRecorder() - router.ServeHTTP(w, createAuthenticatedRequest(http.MethodGet, "/missing/"+id, &bytes.Buffer{}, token)) - Expect(w.Code).To(Equal(status), w.Body.String()) - }, - Entry("returns a missing file", "missing-1", http.StatusOK), - Entry("returns 404 for a file that is not missing", "present-1", http.StatusNotFound), - Entry("returns 404 for an unknown id", "unknown", http.StatusNotFound), - ) -}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 97ad14be2..669c4d7b5 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -2,6 +2,8 @@ package nativeapi import ( "context" + "encoding/json" + "html" "net/http" "strconv" "time" @@ -11,11 +13,8 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/metrics" playlistsvc "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -45,13 +44,11 @@ type Router struct { users core.User maintenance core.Maintenance pluginManager PluginManager - imgUpload artwork.Uploader - provider external.Provider - quickConnect quickconnect.QuickConnect + imgUpload core.ImageUploadService } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider, quickConnect quickconnect.QuickConnect) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider, quickConnect: quickConnect} +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload} r.Handler = r.routes() return r } @@ -60,25 +57,24 @@ func (api *Router) routes() http.Handler { r := chi.NewRouter() // Public - rx(r, "/translation", newTranslationRepository(), false) + api.RX(r, "/translation", newTranslationRepository, false) // Protected r.Group(func(r chi.Router) { r.Use(server.Authenticator(api.ds)) r.Use(server.JWTRefresher) r.Use(server.UpdateLastAccessMiddleware(api.ds)) - rx(r, "/user", api.users.Repository(), true) - rx(r, "/song", api.ds.MediaFile(), false) - rx(r, "/album", api.ds.Album(), false) + api.RX(r, "/user", api.users.NewRepository, true) + api.R(r, "/song", model.MediaFile{}, false) + api.R(r, "/album", model.Album{}, false) api.addArtistRoute(r) - rx(r, "/genre", api.ds.Genre(), false) - rx(r, "/player", api.ds.Player(), true) - rx(r, "/transcoding", api.ds.Transcoding(), conf.Server.EnableTranscodingConfig) + api.R(r, "/genre", model.Genre{}, false) + api.R(r, "/player", model.Player{}, true) + api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) api.addRadioRoute(r) - rx(r, "/tag", api.ds.Tag(), false) - rx(r, "/scrobble", api.ds.Scrobble(), false) + api.R(r, "/tag", model.Tag{}, true) if conf.Server.EnableSharing { - rx(r, "/share", api.share.Repository(), true) + api.RX(r, "/share", api.share.NewRepository, true) } api.addPlaylistRoute(r) @@ -88,46 +84,53 @@ func (api *Router) routes() http.Handler { api.addMissingFilesRoute(r) api.addKeepAliveRoute(r) api.addInsightsRoute(r) - api.addQuickConnectRoute(r) r.With(adminOnlyMiddleware).Group(func(r chi.Router) { api.addInspectRoute(r) api.addConfigRoute(r) api.addUserLibraryRoute(r) api.addPluginRoute(r) - api.addMetadataRoute(r) - rx(r, "/library", api.libs.Repository(), true) + api.RX(r, "/library", api.libs.NewRepository, true) }) }) return r } -func rx[T any](r chi.Router, pathPrefix string, repo rest.Repository[T], persistable bool) { +func (api *Router) R(r chi.Router, pathPrefix string, model any, persistable bool) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model) + } + api.RX(r, pathPrefix, constructor, persistable) +} + +func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) { r.Route(pathPrefix, func(r chi.Router) { - r.Get("/", rest.GetAll(repo)) + r.Get("/", rest.GetAll(constructor)) if persistable { - r.Post("/", rest.Post(repo)) + r.Post("/", rest.Post(constructor)) } r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(repo)) + r.Get("/", rest.Get(constructor)) if persistable { - r.Put("/", rest.Put(repo)) - r.Delete("/", rest.Delete(repo)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) } }) }) } func (api *Router) addPlaylistRoute(r chi.Router) { - repo := api.playlists.Repository() + constructor := func(ctx context.Context) rest.Repository { + return api.playlists.NewRepository(ctx) + } r.Route("/playlist", func(r chi.Router) { - r.Get("/", rest.GetAll(repo)) + r.Get("/", rest.GetAll(constructor)) r.Post("/", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Content-type") == "application/json" { - rest.Post(repo)(w, r) + rest.Post(constructor)(w, r) return } createPlaylistFromM3U(api.playlists)(w, r) @@ -135,9 +138,9 @@ func (api *Router) addPlaylistRoute(r chi.Router) { r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(repo)) - r.Put("/", rest.Put(repo)) - r.Delete("/", rest.Delete(repo)) + r.Get("/", rest.Get(constructor)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) r.Post("/image", uploadPlaylistImage(api.playlists)) r.Delete("/image", deletePlaylistImage(api.playlists)) }) @@ -189,24 +192,28 @@ func (api *Router) addQueueRoute(r chi.Router) { func (api *Router) addMissingFilesRoute(r chi.Router) { r.Route("/missing", func(r chi.Router) { - rx(r, "/", newMissingRepository(api.ds), false) + api.RX(r, "/", newMissingRepository(api.ds), false) r.Delete("/", deleteMissingFiles(api.maintenance)) }) } func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []string) { - var payload any + var resp []byte + var err error if len(ids) == 1 { - payload = struct { - ID string `json:"id"` - }{ID: ids[0]} + resp = []byte(`{"id":"` + html.EscapeString(ids[0]) + `"}`) } else { - payload = struct { + resp, err = json.Marshal(&struct { Ids []string `json:"ids"` - }{Ids: ids} + }{Ids: ids}) + if err != nil { + log.Error(r.Context(), "Error marshaling response", "ids", ids, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + } } - if err := rest.RespondWithJSON(w, http.StatusOK, payload); err != nil { - log.Error(r.Context(), "Error writing response", "ids", ids, err) + _, err = w.Write(resp) //nolint:gosec + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) } } diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index f151b1d72..f0ee50ebb 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -2,7 +2,6 @@ package nativeapi import ( "bytes" - "context" "encoding/json" "net/http" "net/http/httptest" @@ -13,7 +12,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/tests" @@ -23,7 +21,6 @@ import ( var _ = Describe("Song Endpoints", func() { var ( - ctx context.Context router http.Handler ds *tests.MockDataStore mfRepo *tests.MockMediaFileRepo @@ -34,9 +31,7 @@ var _ = Describe("Song Endpoints", func() { ) BeforeEach(func() { - ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false conf.Server.SessionTimeout = time.Minute // Setup mock repositories @@ -60,7 +55,7 @@ var _ = Describe("Song Endpoints", func() { IsAdmin: false, NewPassword: "testpass", } - err := userRepo.Put(ctx, &testUser) + err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) // Create test songs @@ -99,7 +94,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) @@ -373,7 +368,7 @@ var _ = Describe("Song Endpoints", func() { IsAdmin: true, NewPassword: "adminpass", } - err := userRepo.Put(ctx, &adminUser) + err := userRepo.Put(&adminUser) Expect(err).ToNot(HaveOccurred()) // Create JWT token for admin user @@ -396,7 +391,7 @@ var _ = Describe("Song Endpoints", func() { IsAdmin: false, NewPassword: "userpass", } - err := userRepo.Put(ctx, ®ularUser) + err := userRepo.Put(®ularUser) Expect(err).ToNot(HaveOccurred()) // Create JWT token for regular user diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 82f138492..ea1cf579b 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -16,24 +16,11 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/str" ) -// writePlaylistError maps a playlist service error to an HTTP status, or defaultStatus if unknown. -func writePlaylistError(w http.ResponseWriter, err error, defaultStatus int) { - switch { - case errors.Is(err, model.ErrNotFound): - http.Error(w, err.Error(), http.StatusNotFound) - case errors.Is(err, model.ErrNotAuthorized): - http.Error(w, err.Error(), http.StatusForbidden) - case errors.Is(err, model.ErrPlaylistNotEditable): - http.Error(w, err.Error(), http.StatusConflict) - default: - http.Error(w, err.Error(), defaultStatus) - } -} +type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc -func playlistTracksHandler(pls playlists.Playlists, handler func(rest.Repository[model.PlaylistTrack]) http.HandlerFunc, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc { +func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { plsId := chi.URLParam(r, "playlistId") tracks := pls.TracksRepository(r.Context(), plsId, refreshSmartPlaylist(r)) @@ -41,12 +28,12 @@ func playlistTracksHandler(pls playlists.Playlists, handler func(rest.Repository http.Error(w, "not found", http.StatusNotFound) return } - handler(tracks).ServeHTTP(w, r) + handler(func(ctx context.Context) rest.Repository { return tracks }).ServeHTTP(w, r) } } func getPlaylist(pls playlists.Playlists) http.HandlerFunc { - handler := playlistTracksHandler(pls, rest.GetAll[model.PlaylistTrack], func(r *http.Request) bool { + handler := playlistTracksHandler(pls, rest.GetAll, func(r *http.Request) bool { return req.Params(r).Int64Or("_start", 0) == 0 }) return func(w http.ResponseWriter, r *http.Request) { @@ -59,7 +46,7 @@ func getPlaylist(pls playlists.Playlists) http.HandlerFunc { } func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc { - return playlistTracksHandler(pls, rest.Get[model.PlaylistTrack], func(*http.Request) bool { return true }) + return playlistTracksHandler(pls, rest.Get, func(*http.Request) bool { return true }) } func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc { @@ -100,7 +87,8 @@ func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc { log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name) w.Header().Set("Content-Type", "audio/x-mpegurl") - w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(playlist.Name+".m3u")) + disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", playlist.Name) + w.Header().Set("Content-Disposition", disposition) _, err = w.Write([]byte(playlist.ToM3U8())) //nolint:gosec if err != nil { @@ -114,7 +102,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p := req.Params(r) playlistId, _ := p.String(":playlistId") - ids := p.Strings("id") + ids, _ := p.Strings("id") err := pls.RemoveTracks(r.Context(), playlistId, ids) if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0]) @@ -123,7 +111,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { } if err != nil { log.Error(r.Context(), "Error deleting tracks from playlist", "playlistId", playlistId, "ids", ids, err) - writePlaylistError(w, err, http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusInternalServerError) return } writeDeleteManyResponse(w, r, ids) @@ -150,22 +138,22 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc { } count, c := 0, 0 if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil { - writePlaylistError(w, err, http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } count += c if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil { - writePlaylistError(w, err, http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } count += c if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil { - writePlaylistError(w, err, http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } count += c if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil { - writePlaylistError(w, err, http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } count += c @@ -204,8 +192,12 @@ func reorderItem(pls playlists.Playlists) http.HandlerFunc { return } err = pls.ReorderTrack(ctx, playlistId, id, newPos) + if errors.Is(err, model.ErrNotAuthorized) { + http.Error(w, err.Error(), http.StatusForbidden) + return + } if err != nil { - writePlaylistError(w, err, http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 82e3bc86a..e1c933709 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -9,7 +9,6 @@ import ( "time" "github.com/deluan/rest" - "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -77,7 +76,6 @@ var _ = Describe("Playlist Tracks Endpoint", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false conf.Server.SessionTimeout = time.Minute plsSvc = &mockPlaylistsService{} @@ -97,10 +95,10 @@ var _ = Describe("Playlist Tracks Endpoint", func() { IsAdmin: false, NewPassword: "testpass", } - err := userRepo.Put(GinkgoT().Context(), &testUser) + err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) @@ -184,66 +182,28 @@ var _ = Describe("Playlist Tracks Endpoint", func() { }) }) -var _ = Describe("handleExportPlaylist", func() { - export := func(name string) *httptest.ResponseRecorder { - r := chi.NewRouter() - r.Get("/playlist/{playlistId}", handleExportPlaylist(&mockPlaylistsService{ - playlist: &model.Playlist{ID: "pls-1", Name: name}, - })) - w := httptest.NewRecorder() - r.ServeHTTP(w, httptest.NewRequest("GET", "/playlist/pls-1", nil)) - return w - } - - It("names the download after the playlist", func() { - w := export("Road Trip") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="Road Trip.m3u"`)) - }) - - It("does not let the playlist name inject a second filename parameter", func() { - w := export(`party"; filename="evil.html`) - - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="party_; filename=_evil.html.m3u"`)) - }) - - It("keeps non-ASCII names in filename*", func() { - w := export("Кино") - - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="download.m3u"; filename*=utf-8''%D0%9A%D0%B8%D0%BD%D0%BE.m3u`)) - }) -}) - -var _ = Describe("writePlaylistError", func() { - DescribeTable("maps a service error to an HTTP status", - func(err error, expected int) { - w := httptest.NewRecorder() - writePlaylistError(w, err, http.StatusBadRequest) - Expect(w.Code).To(Equal(expected)) - }, - Entry("not found -> 404", model.ErrNotFound, http.StatusNotFound), - Entry("not authorized -> 403", model.ErrNotAuthorized, http.StatusForbidden), - Entry("rest permission denied -> 403", rest.ErrPermissionDenied, http.StatusForbidden), - Entry("not editable -> 409", model.ErrPlaylistNotEditable, http.StatusConflict), - Entry("unrecognized -> default", model.ErrValidation, http.StatusBadRequest), - ) -}) - type mockPlaylistTrackRepo struct { model.PlaylistTrackRepository tracks model.PlaylistTracks } -func (m *mockPlaylistTrackRepo) Count(context.Context, ...rest.QueryOptions) (int64, error) { +func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) { return int64(len(m.tracks)), nil } -func (m *mockPlaylistTrackRepo) ReadAll(context.Context, ...rest.QueryOptions) ([]model.PlaylistTrack, error) { +func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) { return m.tracks, nil } -func (m *mockPlaylistTrackRepo) Read(_ context.Context, id string) (*model.PlaylistTrack, error) { +func (m *mockPlaylistTrackRepo) EntityName() string { + return "playlist_track" +} + +func (m *mockPlaylistTrackRepo) NewInstance() any { + return &model.PlaylistTrack{} +} + +func (m *mockPlaylistTrackRepo) Read(id string) (any, error) { for _, t := range m.tracks { if t.ID == id { return &t, nil @@ -254,9 +214,7 @@ func (m *mockPlaylistTrackRepo) Read(_ context.Context, id string) (*model.Playl type mockPlaylistsService struct { playlists.Playlists - repo rest.Repository[model.Playlist] - tracksRepo rest.Repository[model.PlaylistTrack] - playlist *model.Playlist + tracksRepo rest.Repository removeImageFn func(ctx context.Context, id string) error setImageFn func(ctx context.Context, id string, reader io.Reader, ext string) error } @@ -275,17 +233,6 @@ func (m *mockPlaylistsService) SetImage(ctx context.Context, id string, reader i return model.ErrNotFound } -func (m *mockPlaylistsService) Repository() rest.Repository[model.Playlist] { - return m.repo -} - -func (m *mockPlaylistsService) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) { - if m.playlist == nil { - return nil, model.ErrNotFound - } - return m.playlist, nil -} - -func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository[model.PlaylistTrack] { +func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository { return m.tracksRepo } diff --git a/server/nativeapi/plugin.go b/server/nativeapi/plugin.go index d34bf23fc..a7d261681 100644 --- a/server/nativeapi/plugin.go +++ b/server/nativeapi/plugin.go @@ -15,15 +15,17 @@ import ( ) func (api *Router) addPluginRoute(r chi.Router) { - repo := api.ds.Plugin() + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Plugin(ctx) + } r.Route("/plugin", func(r chi.Router) { r.Use(pluginsEnabledMiddleware) - r.Get("/", rest.GetAll(repo)) + r.Get("/", rest.GetAll(constructor)) r.Post("/rescan", api.rescanPlugins) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(repo)) + r.Get("/", rest.Get(constructor)) r.Put("/", api.updatePlugin) }) }) @@ -66,10 +68,10 @@ type PluginUpdateRequest struct { func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") ctx := r.Context() - repo := api.ds.Plugin() + repo := api.ds.Plugin(ctx) // Get existing plugin to verify it exists - if _, err := repo.Get(ctx, id); err != nil { + if _, err := repo.Get(id); err != nil { if errors.Is(err, rest.ErrPermissionDenied) { http.Error(w, "Access denied: admin privileges required", http.StatusForbidden) return @@ -121,7 +123,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { if enableErr := api.pluginManager.EnablePlugin(ctx, id); enableErr != nil { log.Error(ctx, "Error enabling plugin", "id", id, enableErr) // Refresh plugin from DB to get the error - plugin, err := repo.Get(ctx, id) + plugin, err := repo.Get(id) if err != nil { log.Error(ctx, "Error getting updated plugin after enable failure", "id", id, err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -151,7 +153,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { } // Refresh and return updated plugin - plugin, err := repo.Get(ctx, id) + plugin, err := repo.Get(id) if err != nil { log.Error(ctx, "Error getting updated plugin", "id", id, err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -202,7 +204,7 @@ func validateAndUpdateConfig(ctx context.Context, pm PluginManager, id, configJS // Returns an error if validation or update fails (error response already written). func validateAndUpdateUsers(ctx context.Context, pm PluginManager, repo model.PluginRepository, id string, req PluginUpdateRequest, w http.ResponseWriter) error { // Get current values if not provided in request - plugin, err := repo.Get(ctx, id) + plugin, err := repo.Get(id) if err != nil { log.Error(ctx, "Error getting plugin for users update", "id", id, err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -235,7 +237,7 @@ func validateAndUpdateUsers(ctx context.Context, pm PluginManager, repo model.Pl // Returns an error if validation or update fails (error response already written). func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo model.PluginRepository, id string, req PluginUpdateRequest, w http.ResponseWriter) error { // Get current values if not provided in request - plugin, err := repo.Get(ctx, id) + plugin, err := repo.Get(id) if err != nil { log.Error(ctx, "Error getting plugin for libraries update", "id", id, err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index c18d61e65..8fc88e09c 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" @@ -22,7 +21,6 @@ import ( ) var _ = Describe("Plugin API", func() { - var ctx context.Context var ds *tests.MockDataStore var mockManager *tests.MockPluginManager var router http.Handler @@ -30,14 +28,12 @@ var _ = Describe("Plugin API", func() { var testPlugin1, testPlugin2 model.Plugin BeforeEach(func() { - ctx = GinkgoT().Context() DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false conf.Server.Plugins.Enabled = true ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil) router = server.JWTVerifier(nativeRouter) // Create test users @@ -74,8 +70,8 @@ var _ = Describe("Plugin API", func() { } // Store users in mock datastore - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) - Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) + Expect(ds.User(GinkgoT().Context()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(GinkgoT().Context()).Put(®ularUser)).To(Succeed()) }) Context("when plugins are disabled", func() { @@ -107,9 +103,10 @@ var _ = Describe("Plugin API", func() { Expect(err).ToNot(HaveOccurred()) // Store test plugins as admin + ctx := GinkgoT().Context() adminCtx := request.WithUser(ctx, adminUser) - Expect(ds.Plugin().Put(adminCtx, &testPlugin1)).To(Succeed()) - Expect(ds.Plugin().Put(adminCtx, &testPlugin2)).To(Succeed()) + Expect(ds.Plugin(adminCtx).Put(&testPlugin1)).To(Succeed()) + Expect(ds.Plugin(adminCtx).Put(&testPlugin2)).To(Succeed()) }) Describe("GET /api/plugin", func() { @@ -162,9 +159,9 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when EnablePlugin is called mockManager.EnablePluginFn = func(ctx context.Context, id string) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) + p, _ := ds.Plugin(adminCtx).Get(id) p.Enabled = true - return ds.Plugin().Put(adminCtx, p) + return ds.Plugin(adminCtx).Put(p) } body := bytes.NewBufferString(`{"enabled":true}`) @@ -188,9 +185,9 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginConfig is called mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) + p, _ := ds.Plugin(adminCtx).Get(id) p.Config = configJSON - return ds.Plugin().Put(adminCtx, p) + return ds.Plugin(adminCtx).Put(p) } body := bytes.NewBufferString(`{"config":"{\"key\":\"value\"}"}`) @@ -228,9 +225,9 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginConfig is called mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) + p, _ := ds.Plugin(adminCtx).Get(id) p.Config = configJSON - return ds.Plugin().Put(adminCtx, p) + return ds.Plugin(adminCtx).Put(p) } body := bytes.NewBufferString(`{"config":""}`) @@ -253,10 +250,10 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginUsers is called mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) + p, _ := ds.Plugin(adminCtx).Get(id) p.Users = usersJSON p.AllUsers = allUsers - return ds.Plugin().Put(adminCtx, p) + return ds.Plugin(adminCtx).Put(p) } body := bytes.NewBufferString(`{"users":"[\"user1\",\"user2\"]"}`) @@ -281,10 +278,10 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginUsers is called mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) + p, _ := ds.Plugin(adminCtx).Get(id) p.Users = usersJSON p.AllUsers = allUsers - return ds.Plugin().Put(adminCtx, p) + return ds.Plugin(adminCtx).Put(p) } body := bytes.NewBufferString(`{"allUsers":true}`) @@ -309,10 +306,10 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginUsers is called mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) + p, _ := ds.Plugin(adminCtx).Get(id) p.Users = usersJSON p.AllUsers = allUsers - return ds.Plugin().Put(adminCtx, p) + return ds.Plugin(adminCtx).Put(p) } body := bytes.NewBufferString(`{"users":"[\"user1\"]","allUsers":false}`) @@ -350,10 +347,10 @@ var _ = Describe("Plugin API", func() { // Configure mock to update the repo when UpdatePluginUsers is called mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) + p, _ := ds.Plugin(adminCtx).Get(id) p.Users = usersJSON p.AllUsers = allUsers - return ds.Plugin().Put(adminCtx, p) + return ds.Plugin(adminCtx).Put(p) } body := bytes.NewBufferString(`{"users":""}`) diff --git a/server/nativeapi/queue.go b/server/nativeapi/queue.go index 05188106c..a7700c02c 100644 --- a/server/nativeapi/queue.go +++ b/server/nativeapi/queue.go @@ -32,7 +32,7 @@ func validateCurrentIndex(w http.ResponseWriter, current int, itemsLength int) b // retrieveExistingQueue retrieves an existing play queue for a user with proper error handling. // Returns the queue (nil if not found) and false if an error occurred and response was sent. func retrieveExistingQueue(ctx context.Context, w http.ResponseWriter, ds model.DataStore, userID string) (*model.PlayQueue, bool) { - existing, err := ds.PlayQueue().Retrieve(ctx, userID) + existing, err := ds.PlayQueue(ctx).Retrieve(userID) if err != nil && !errors.Is(err, model.ErrNotFound) { log.Error(ctx, "Error retrieving queue", err) http.Error(w, err.Error(), http.StatusInternalServerError) @@ -70,8 +70,8 @@ func getQueue(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() user, _ := request.UserFrom(ctx) - repo := ds.PlayQueue() - pq, err := repo.RetrieveWithMediaFiles(ctx, user.ID) + repo := ds.PlayQueue(ctx) + pq, err := repo.RetrieveWithMediaFiles(user.ID) if err != nil && !errors.Is(err, model.ErrNotFound) { log.Error(ctx, "Error retrieving queue", err) http.Error(w, err.Error(), http.StatusInternalServerError) @@ -112,7 +112,7 @@ func saveQueue(ds model.DataStore) http.HandlerFunc { ChangedBy: client, Items: items, } - if err := ds.PlayQueue().Store(ctx, pq); err != nil { + if err := ds.PlayQueue(ctx).Store(pq); err != nil { log.Error(ctx, "Error saving queue", err) http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -191,7 +191,7 @@ func updateQueue(ds model.DataStore) http.HandlerFunc { } // Perform partial update of the specified columns only - if err := ds.PlayQueue().Store(ctx, pq, cols...); err != nil { + if err := ds.PlayQueue(ctx).Store(pq, cols...); err != nil { log.Error(ctx, "Error updating queue", err) http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -204,7 +204,7 @@ func clearQueue(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() user, _ := request.UserFrom(ctx) - if err := ds.PlayQueue().Clear(ctx, user.ID); err != nil { + if err := ds.PlayQueue(ctx).Clear(user.ID); err != nil { log.Error(ctx, "Error clearing queue", err) http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/server/nativeapi/queue_test.go b/server/nativeapi/queue_test.go index 6e68fec0e..ef971ee68 100644 --- a/server/nativeapi/queue_test.go +++ b/server/nativeapi/queue_test.go @@ -9,6 +9,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" ) @@ -25,13 +26,13 @@ var _ = Describe("Queue Endpoints", func() { repo = &tests.MockPlayQueueRepo{} user = model.User{ID: "u1", UserName: "user"} userRepo = tests.CreateMockUserRepo() - _ = userRepo.Put(GinkgoT().Context(), &user) + _ = userRepo.Put(&user) ds = &tests.MockDataStore{MockedPlayQueue: repo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}} }) Describe("POST /queue", func() { It("saves the queue", func() { - payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1), Position: new(int64(10))} + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) ctx := request.WithUser(req.Context(), user) @@ -49,7 +50,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("saves an empty queue", func() { - payload := updateQueuePayload{Ids: new([]string{}), Current: new(0), Position: new(int64(0))} + payload := updateQueuePayload{Ids: gg.P([]string{}), Current: gg.P(0), Position: gg.P(int64(0))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -62,7 +63,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("returns bad request for invalid current index (negative)", func() { - payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(-1), Position: new(int64(10))} + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -74,7 +75,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("returns bad request for invalid current index (too large)", func() { - payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(2), Position: new(int64(10))} + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -96,7 +97,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns internal server error when store fails", func() { repo.Err = true - payload := updateQueuePayload{Ids: new([]string{"s1"}), Current: new(0), Position: new(int64(10))} + payload := updateQueuePayload{Ids: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -165,7 +166,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: new(2), Position: new(int64(20))} + payload := updateQueuePayload{Current: gg.P(2), Position: gg.P(int64(20))} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) ctx := request.WithUser(req.Context(), user) @@ -183,7 +184,7 @@ var _ = Describe("Queue Endpoints", func() { It("updates only ids", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1} - payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})} + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -197,7 +198,7 @@ var _ = Describe("Queue Endpoints", func() { It("updates ids and current", func() { repo.Queue = &model.PlayQueue{UserID: user.ID} - payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1)} + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1)} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -212,7 +213,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: new([]string{"s1", "s2"})} + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -224,7 +225,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: new(3)} + payload := updateQueuePayload{Current: gg.P(3)} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -245,7 +246,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns internal server error when store fails", func() { repo.Err = true - payload := updateQueuePayload{Position: new(int64(10))} + payload := updateQueuePayload{Position: gg.P(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/nativeapi/quickconnect.go b/server/nativeapi/quickconnect.go deleted file mode 100644 index 61d402cc5..000000000 --- a/server/nativeapi/quickconnect.go +++ /dev/null @@ -1,76 +0,0 @@ -package nativeapi - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server" -) - -type quickConnectDevice struct { - AppName string `json:"appName"` - AppVersion string `json:"appVersion"` - DeviceName string `json:"deviceName"` -} - -func (api *Router) addQuickConnectRoute(r chi.Router) { - if !quickconnect.Enabled() { - return - } - r.Route("/quickconnect", func(r chi.Router) { - // Throttled like login so a signed-in user cannot enumerate other people's pending codes. - if conf.Server.AuthRequestLimit > 0 { - r.Use(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) - } - r.Get("/", lookupQuickConnect(api.quickConnect)) - r.Post("/authorize", authorizeQuickConnect(api.quickConnect)) - }) -} - -func lookupQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - req, err := qc.Lookup(r.URL.Query().Get("code")) - writeQuickConnectResult(w, r, req, err) - } -} - -func authorizeQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var body struct { - Code string `json:"code"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - ctx := r.Context() - user, _ := request.UserFrom(ctx) - req, err := qc.Authorize(body.Code, user.ID) - if err == nil { - log.Info(ctx, "Quick Connect sign-in approved", "username", user.UserName, "client", req.Device.App, "device", req.Device.Name) - } - writeQuickConnectResult(w, r, req, err) - } -} - -func writeQuickConnectResult(w http.ResponseWriter, r *http.Request, req quickconnect.Request, err error) { - switch { - case errors.Is(err, model.ErrNotFound): - http.Error(w, "Unknown code", http.StatusNotFound) - case errors.Is(err, quickconnect.ErrAlreadyAuthorized): - http.Error(w, "Code already used", http.StatusConflict) - case err != nil: - log.Error(r.Context(), "Quick Connect failed", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - default: - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(quickConnectDevice{AppName: req.Device.App, AppVersion: req.Device.AppVersion, DeviceName: req.Device.Name}) - } -} diff --git a/server/nativeapi/quickconnect_test.go b/server/nativeapi/quickconnect_test.go deleted file mode 100644 index f2f06f0f3..000000000 --- a/server/nativeapi/quickconnect_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package nativeapi - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Quick Connect endpoints", func() { - var ( - qc quickconnect.QuickConnect - pending quickconnect.Request - user = model.User{ID: "u1", UserName: "alice"} - ) - - BeforeEach(func() { - qc = quickconnect.New() - var err error - pending, err = qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}) - Expect(err).ToNot(HaveOccurred()) - }) - - asUser := func(r *http.Request) *http.Request { - return r.WithContext(request.WithUser(r.Context(), user)) - } - decode := func(w *httptest.ResponseRecorder) quickConnectDevice { - var res quickConnectDevice - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - return res - } - finamp := quickConnectDevice{AppName: "Finamp", AppVersion: "1.0.0", DeviceName: "Pixel 7"} - - Describe("GET /quickconnect", func() { - lookup := func(code string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - lookupQuickConnect(qc)(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+code, nil))) - return w - } - - It("describes the device waiting for the code", func() { - w := lookup(pending.Code) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(decode(w)).To(Equal(finamp)) - }) - - It("does not approve the code", func() { - lookup(pending.Code) - got, _ := qc.Status(pending.Secret) - Expect(got.Authorized()).To(BeFalse()) - }) - - It("returns 404 for an unknown code", func() { - Expect(lookup("000000").Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 409 for a code that is already approved", func() { - _, _ = qc.Authorize(pending.Code, "someone") - Expect(lookup(pending.Code).Code).To(Equal(http.StatusConflict)) - }) - }) - - Describe("POST /quickconnect/authorize", func() { - authorize := func(body string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - authorizeQuickConnect(qc)(w, asUser(httptest.NewRequest("POST", "/quickconnect/authorize", strings.NewReader(body)))) - return w - } - - It("approves the code for the signed-in user", func() { - w := authorize(`{"code":"` + pending.Code + `"}`) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(decode(w)).To(Equal(finamp)) - got, _ := qc.Status(pending.Secret) - Expect(got.UserID).To(Equal(user.ID)) - }) - - It("returns 404 for an unknown code", func() { - Expect(authorize(`{"code":"000000"}`).Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 409 for a code that is already approved", func() { - _, _ = qc.Authorize(pending.Code, "someone") - Expect(authorize(`{"code":"` + pending.Code + `"}`).Code).To(Equal(http.StatusConflict)) - }) - - It("returns 400 for a bad body", func() { - Expect(authorize(`nope`).Code).To(Equal(http.StatusBadRequest)) - }) - }) - - Describe("route registration", func() { - serve := func() int { - r := chi.NewRouter() - (&Router{quickConnect: qc}).addQuickConnectRoute(r) - w := httptest.NewRecorder() - r.ServeHTTP(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+pending.Code, nil))) - return w.Code - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.Enabled = true - conf.Server.Jellyfin.QuickConnect = true - }) - - It("mounts the routes when Jellyfin Quick Connect is on", func() { - Expect(serve()).To(Equal(http.StatusOK)) - }) - - It("rate-limits code attempts when a login limit is configured", func() { - conf.Server.AuthRequestLimit = 2 - conf.Server.AuthWindowLength = time.Minute - r := chi.NewRouter() - (&Router{quickConnect: qc}).addQuickConnectRoute(r) - attempt := func() int { - w := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/quickconnect?code=000000", nil) - req.RemoteAddr = "10.0.0.1:1234" - r.ServeHTTP(w, asUser(req)) - return w.Code - } - Expect(attempt()).To(Equal(http.StatusNotFound)) - Expect(attempt()).To(Equal(http.StatusNotFound)) - Expect(attempt()).To(Equal(http.StatusTooManyRequests)) - }) - - It("skips the routes when the Jellyfin API is off", func() { - conf.Server.Jellyfin.Enabled = false - Expect(serve()).To(Equal(http.StatusNotFound)) - }) - - It("skips the routes when Quick Connect is off", func() { - conf.Server.Jellyfin.QuickConnect = false - Expect(serve()).To(Equal(http.StatusNotFound)) - }) - }) -}) diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go index 4bacbea7a..701c6c926 100644 --- a/server/nativeapi/radios.go +++ b/server/nativeapi/radios.go @@ -14,15 +14,17 @@ import ( ) func (api *Router) addRadioRoute(r chi.Router) { - repo := api.ds.Radio() + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.Radio{}) + } r.Route("/radio", func(r chi.Router) { - r.Get("/", rest.GetAll(repo)) - r.Post("/", rest.Post(repo)) + r.Get("/", rest.GetAll(constructor)) + r.Post("/", rest.Post(constructor)) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(repo)) - r.Put("/", rest.Put(repo)) - r.Delete("/", rest.Delete(repo)) + r.Get("/", rest.Get(constructor)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) r.Post("/image", api.uploadRadioImage()) r.Delete("/image", api.deleteRadioImage()) }) @@ -32,7 +34,7 @@ func (api *Router) addRadioRoute(r chi.Router) { func (api *Router) uploadRadioImage() http.HandlerFunc { return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { radioID := chi.URLParamFromCtx(ctx, "id") - radio, err := api.ds.Radio().Get(ctx, radioID) + radio, err := api.ds.Radio(ctx).Get(radioID) if err != nil { if errors.Is(err, model.ErrNotFound) { return model.ErrNotFound @@ -45,18 +47,14 @@ func (api *Router) uploadRadioImage() http.HandlerFunc { return err } radio.UploadedImage = filename - if err := api.ds.Radio().Put(ctx, radio, "UploadedImage"); err != nil { - return err - } - api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) - return nil + return api.ds.Radio(ctx).Put(radio, "UploadedImage") }) } func (api *Router) deleteRadioImage() http.HandlerFunc { return handleImageDelete(func(ctx context.Context) error { radioID := chi.URLParamFromCtx(ctx, "id") - radio, err := api.ds.Radio().Get(ctx, radioID) + radio, err := api.ds.Radio(ctx).Get(radioID) if err != nil { if errors.Is(err, model.ErrNotFound) { return model.ErrNotFound @@ -67,10 +65,6 @@ func (api *Router) deleteRadioImage() http.HandlerFunc { return err } radio.UploadedImage = "" - if err := api.ds.Radio().Put(ctx, radio, "UploadedImage"); err != nil { - return err - } - api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) - return nil + return api.ds.Radio(ctx).Put(radio, "UploadedImage") }) } diff --git a/server/nativeapi/translations.go b/server/nativeapi/translations.go index fc4651c4c..685713083 100644 --- a/server/nativeapi/translations.go +++ b/server/nativeapi/translations.go @@ -17,34 +17,33 @@ import ( ) type translation struct { - ID string `json:"id"` - Name string `json:"name"` - Data string `json:"data"` - TermCount int `json:"termCount"` + ID string `json:"id"` + Name string `json:"name"` + Data string `json:"data"` } -func newTranslationRepository() rest.Repository[translation] { +func newTranslationRepository(context.Context) rest.Repository { return &translationRepository{} } type translationRepository struct{} -func (r *translationRepository) Read(_ context.Context, id string) (*translation, error) { +func (r *translationRepository) Read(id string) (any, error) { translations, _ := loadTranslations() if t, ok := translations[id]; ok { - return &t, nil + return t, nil } return nil, rest.ErrNotFound } // Count simple implementation, does not support any `options` -func (r *translationRepository) Count(context.Context, ...rest.QueryOptions) (int64, error) { +func (r *translationRepository) Count(...rest.QueryOptions) (int64, error) { _, count := loadTranslations() return count, nil } // ReadAll simple implementation, only returns IDs. Does not support any `options` -func (r *translationRepository) ReadAll(context.Context, ...rest.QueryOptions) ([]translation, error) { +func (r *translationRepository) ReadAll(...rest.QueryOptions) (any, error) { translations, _ := loadTranslations() var result []translation for _, t := range translations { @@ -54,6 +53,14 @@ func (r *translationRepository) ReadAll(context.Context, ...rest.QueryOptions) ( return result, nil } +func (r *translationRepository) EntityName() string { + return "translation" +} + +func (r *translationRepository) NewInstance() any { + return &translation{} +} + var loadTranslations = sync.OnceValues(func() (map[string]translation, int64) { translations := make(map[string]translation) fsys := resources.FS() @@ -90,46 +97,27 @@ func loadTranslation(fsys fs.FS, fileName string) (translation translation, err // Load translation from json file file, err := fsys.Open(filePath) if err != nil { - return translation, err + return } data, err := io.ReadAll(file) if err != nil { - return translation, err + return } var out map[string]any if err = json.Unmarshal(data, &out); err != nil { - return translation, err + return } // Compress JSON buf := new(bytes.Buffer) if err = json.Compact(buf, data); err != nil { - return translation, err + return } translation.Data = buf.String() translation.Name = out["languageName"].(string) translation.ID = id - translation.TermCount = countTranslatedTerms(out) - return translation, nil + return } -// countTranslatedTerms counts non-empty leaf values, matching the UI's notion of a translated term -func countTranslatedTerms(obj map[string]any) int { - count := 0 - for _, v := range obj { - switch v := v.(type) { - case map[string]any: - count += countTranslatedTerms(v) - case string: - if v != "" { - count++ - } - default: - count++ - } - } - return count -} - -var _ rest.Repository[translation] = (*translationRepository)(nil) +var _ rest.Repository = (*translationRepository)(nil) diff --git a/server/nativeapi/translations_test.go b/server/nativeapi/translations_test.go index 1c641da77..6c834070c 100644 --- a/server/nativeapi/translations_test.go +++ b/server/nativeapi/translations_test.go @@ -5,12 +5,11 @@ import ( "io" "io/fs" "os" - "path" "path/filepath" - "testing/fstest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -18,12 +17,13 @@ import ( var _ = Describe("Translations", func() { Describe("I18n files", func() { It("contains only valid json language files", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-nativeapi)") fsys := resources.FS() dir, _ := fsys.Open(consts.I18nFolder) files, _ := dir.(fs.ReadDirFile).ReadDir(-1) for _, f := range files { name := filepath.Base(f.Name()) - filePath := path.Join(consts.I18nFolder, name) + filePath := filepath.Join(consts.I18nFolder, name) file, _ := fsys.Open(filePath) data, _ := io.ReadAll(file) var out map[string]any @@ -45,16 +45,5 @@ var _ = Describe("Translations", func() { var out map[string]any Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil()) }) - - It("counts only non-empty leaf terms", func() { - fsys := fstest.MapFS{ - "i18n/test.json": &fstest.MapFile{ - Data: []byte(`{"languageName":"Test","a":"x","b":"","nested":{"c":"y","d":""}}`), - }, - } - tr, err := loadTranslation(fsys, "test.json") - Expect(err).To(BeNil()) - Expect(tr.TermCount).To(Equal(3)) - }) }) }) diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go deleted file mode 100644 index 81f28893f..000000000 --- a/server/nativeapi/user_password_token_refresh_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package nativeapi - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "time" - - "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/auth" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type noopPluginUnloader struct{} - -func (noopPluginUnloader) UnloadDisabledPlugins(context.Context) {} - -// Pins that the token-epoch handoff survives a real request through the real middleware chain. -var _ = Describe("PUT /user/{id}: token refresh on self password change", func() { - var ctx context.Context - var ds model.DataStore - var router http.Handler - - BeforeEach(func() { - ctx = GinkgoT().Context() - // db.Db() is a process-wide singleton that this DeferCleanup closes for the whole binary; keep this the only real-DB spec in this package. - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableUserEditing = true - conf.Server.EnableSharing = false - conf.Server.SessionTimeout = time.Hour - conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "nativeapi-user-refresh.db") + "?_journal_mode=WAL" - DeferCleanup(db.Init(GinkgoT().Context())) - - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(ds) - - userService := core.NewUser(ds, noopPluginUnloader{}) - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil) - router = server.JWTVerifier(nativeRouter) - }) - - It("carries the bumped epoch in the refreshed token, not the epoch the token was minted with", func() { - usr := model.User{UserName: "selfchanger", Name: "Self Changer", NewPassword: "old-password"} - Expect(ds.User().Put(ctx, &usr)).To(Succeed()) - - token, err := auth.CreateToken(&usr) - Expect(err).ToNot(HaveOccurred()) - - body, _ := json.Marshal(map[string]any{ - "userName": usr.UserName, - "name": usr.Name, - "currentPassword": "old-password", - "password": "new-password", - }) - req := createAuthenticatedRequest(http.MethodPut, "/user/"+usr.ID, bytes.NewBuffer(body), token) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) - - refreshed := w.Header().Get(consts.UIAuthorizationHeader) - Expect(refreshed).ToNot(BeEmpty()) - claims, err := auth.Validate(refreshed) - Expect(err).ToNot(HaveOccurred()) - - reloaded, err := ds.User().Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(reloaded.TokenEpoch).To(Equal(1)) - Expect(claims.Epoch).To(Equal(reloaded.TokenEpoch)) - }) -}) diff --git a/server/public/handle_downloads.go b/server/public/handle_downloads.go index e50d7609a..6aa35c341 100644 --- a/server/public/handle_downloads.go +++ b/server/public/handle_downloads.go @@ -1,38 +1,18 @@ package public import ( - "cmp" "net/http" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/str" ) func (pub *Router) handleDownloads(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() id, err := req.Params(r).String(":id") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - // Load the share before streaming: once ZipShare writes its first byte the - // status is locked at 200, so errors could no longer be reported. - s, err := pub.share.Load(ctx, id) - if err != nil { - checkShareError(ctx, w, err, id) - return - } - if !s.Downloadable { - checkShareError(ctx, w, model.ErrNotAuthorized, id) - return - } - - name := cmp.Or(s.Description, s.ID) - w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(name+".zip")) - w.Header().Set("Content-Type", "application/zip") - - err = pub.archiver.ZipShare(ctx, s, w) - checkShareError(ctx, w, err, id) + err = pub.archiver.ZipShare(r.Context(), id, w) + checkShareError(r.Context(), w, err, id) } diff --git a/server/public/handle_downloads_test.go b/server/public/handle_downloads_test.go deleted file mode 100644 index d2118f47e..000000000 --- a/server/public/handle_downloads_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package public - -import ( - "context" - "errors" - "io" - "mime" - "net/http" - "net/http/httptest" - "time" - - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type mockArchiver struct { - called bool - err error -} - -func (m *mockArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error { - return nil -} - -func (m *mockArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error { - return nil -} - -func (m *mockArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error { - return nil -} - -func (m *mockArchiver) ZipShare(_ context.Context, _ *model.Share, w io.Writer) error { - m.called = true - if m.err != nil { - return m.err - } - _, _ = w.Write([]byte("zip-contents")) - return nil -} - -var _ = Describe("handleDownloads", func() { - var ds *tests.MockDataStore - var shareRepo *tests.MockShareRepo - var archiver *mockArchiver - var pub *Router - - BeforeEach(func() { - ds = &tests.MockDataStore{} - shareRepo = &tests.MockShareRepo{} - ds.MockedShare = shareRepo - archiver = &mockArchiver{} - pub = &Router{ds: ds, archiver: archiver, share: core.NewShare(ds)} - }) - - shareIs := func(s *model.Share) { - shareRepo.ID = s.ID - shareRepo.Entity = s - } - - makeRequest := func(id string) *httptest.ResponseRecorder { - r := httptest.NewRequest("GET", "/public/d/"+id+"?%3Aid="+id, nil) - w := httptest.NewRecorder() - pub.handleDownloads(w, r) - return w - } - - It("sets a Content-Disposition filename from the share description", func() { - shareIs(&model.Share{ID: "abc123", Description: "My Mixtape", Downloadable: true}) - - w := makeRequest("abc123") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="My Mixtape.zip"`)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/zip")) - Expect(archiver.called).To(BeTrue()) - Expect(w.Body.String()).To(Equal("zip-contents")) - }) - - It("falls back to the share ID when there is no description", func() { - shareIs(&model.Share{ID: "abc123", Downloadable: true}) - - w := makeRequest("abc123") - - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="abc123.zip"`)) - }) - - It("sanitizes characters that are unsafe in a filename", func() { - shareIs(&model.Share{ID: "abc123", Description: `AC/DC: Live, 1979`, Downloadable: true}) - - w := makeRequest("abc123") - - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="AC_DC_ Live_ 1979.zip"`)) - }) - - It("sanitizes the UTF-8 filename* as well", func() { - shareIs(&model.Share{ID: "abc123", Description: "Sigur Rós/Live", Downloadable: true}) - - w := makeRequest("abc123") - - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="Sigur Ros_Live.zip"; filename*=utf-8''Sigur%20R%C3%B3s_Live.zip`)) - }) - - It("does not let the share description inject a second filename parameter", func() { - shareIs(&model.Share{ID: "abc123", Description: `mix"; filename="evil.html`, Downloadable: true}) - - w := makeRequest("abc123") - - disposition := w.Header().Get("Content-Disposition") - Expect(disposition).ToNot(ContainSubstring(`filename="evil.html`)) - _, params, err := mime.ParseMediaType(disposition) - Expect(err).ToNot(HaveOccurred()) - Expect(params["filename"]).To(Equal(`mix_; filename=_evil.html.zip`)) - }) - - It("returns 403 without invoking the archiver when the share is not downloadable", func() { - shareIs(&model.Share{ID: "abc123", Description: "No Download", Downloadable: false}) - - w := makeRequest("abc123") - - Expect(w.Code).To(Equal(http.StatusForbidden)) - Expect(archiver.called).To(BeFalse()) - Expect(w.Header().Get("Content-Disposition")).To(BeEmpty()) - }) - - It("returns 404 when the share does not exist", func() { - shareIs(&model.Share{ID: "other", Downloadable: true}) - - w := makeRequest("missing") - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(archiver.called).To(BeFalse()) - }) - - It("returns 410 when the share has expired", func() { - shareIs(&model.Share{ID: "abc123", Downloadable: true, ExpiresAt: new(time.Now().Add(-time.Hour))}) - - w := makeRequest("abc123") - - Expect(w.Code).To(Equal(http.StatusGone)) - Expect(archiver.called).To(BeFalse()) - }) - - It("returns 500 when the share lookup fails", func() { - shareRepo.Error = errors.New("db error") - - w := makeRequest("abc123") - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - Expect(archiver.called).To(BeFalse()) - }) -}) diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 6416190bd..50f9238e5 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -11,8 +11,6 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/utils/req" ) @@ -39,13 +37,10 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid request", http.StatusBadRequest) return } - // The token is the authorization, so the entity check must ask "is it still there", not - // "may this user see it" -- the latter would hide a shared private playlist. - ctx = request.WithUser(ctx, model.User{IsAdmin: true}) size := p.IntOr("size", 0) square := p.BoolOr("square", false) - img, err := pub.artwork.Get(ctx, artId, size, square) + imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square) switch { case errors.Is(err, context.Canceled): return @@ -63,18 +58,17 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { return } - defer img.Close() - if imghttp.WriteImageHeaders(w, r, img, artId.Hash) { - return - } - cnt, err := io.Copy(w, img) + defer imgReader.Close() + w.Header().Set("Cache-Control", "public, max-age=315360000") + w.Header().Set("Last-Modified", lastUpdate.Format(http.TimeFormat)) + cnt, err := io.Copy(w, imgReader) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } } func decodeArtworkID(tokenString string) (model.ArtworkID, error) { - token, err := auth.PublicTokenAuth.Decode(tokenString) + token, err := auth.TokenAuth.Decode(tokenString) if err != nil { return model.ArtworkID{}, err } diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go index d77b6f427..6895241f6 100644 --- a/server/public/handle_images_test.go +++ b/server/public/handle_images_test.go @@ -1,25 +1,15 @@ package public import ( - "bytes" - "context" - "io" - "net/http" - "net/http/httptest" - "net/url" - "github.com/go-chi/jwtauth/v5" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("decodeArtworkID", func() { BeforeEach(func() { - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) + auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) }) It("fails to decode an invalid token", func() { @@ -33,56 +23,3 @@ var _ = Describe("decodeArtworkID", func() { Expect(err).To(HaveOccurred()) }) }) - -type fakeArtwork struct { - artwork.Artwork - img *artwork.Image - err error -} - -func (f *fakeArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { - return f.img, f.err -} - -var _ = Describe("handleImages", func() { - var w *httptest.ResponseRecorder - - newImageRequest := func(claimID string) *http.Request { - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) - token, _ := auth.CreatePublicToken(auth.Claims{ID: claimID}) - return httptest.NewRequest("GET", "/img?:id="+url.QueryEscape(token), nil) - } - - // The handler re-checks that the entity behind the token still exists, so "1" must be live. - var ds *tests.MockDataStore - - BeforeEach(func() { - w = httptest.NewRecorder() - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{{ID: "1"}}) - ds = &tests.MockDataStore{MockedAlbum: albumRepo} - }) - - It("returns 404 when the artwork is unavailable", func() { - pub := &Router{ds: ds, artwork: &fakeArtwork{err: artwork.ErrUnavailable}} - pub.handleImages(w, newImageRequest("al-1")) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 when the artwork is not found", func() { - pub := &Router{ds: ds, artwork: &fakeArtwork{err: model.ErrNotFound}} - pub.handleImages(w, newImageRequest("al-1")) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("serves the image immutable when the token asserts the current hash", func() { - const hash = "0123456789abcdef" - img := &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader([]byte("IMG"))), Hash: hash} - pub := &Router{ds: ds, artwork: &fakeArtwork{img: img}} - pub.handleImages(w, newImageRequest("al-1_"+hash)) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) - Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) - }) - -}) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 367bff501..18bfcc01c 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -58,8 +58,8 @@ func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) { } s = pub.mapShareToM3U(r, *s) - w.Header().Set("Content-Type", "audio/x-mpegurl") w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "audio/x-mpegurl") _, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec } @@ -81,8 +81,8 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s } func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { - s.URL = ShareURL(r.Context(), s.ID) - s.ImageURL = publicurl.ImageURL(r.Context(), s.CoverArtID(), conf.Server.UICoverArtSize) + s.URL = ShareURL(r, s.ID) + s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize) for i := range s.Tracks { s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID) } @@ -92,27 +92,11 @@ func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { for i := range s.Tracks { id := encodeMediafileShare(s, s.Tracks[i].ID) - s.Tracks[i].Path = publicurl.PublicURL(r.Context(), path.Join(consts.URLPathPublic, "s", id), nil) + s.Tracks[i].Path = publicurl.PublicURL(r, path.Join(consts.URLPathPublic, "s", id), nil) } 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 public-link signing secret, a full-server -// compromise that 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_shares_test.go b/server/public/handle_shares_test.go deleted file mode 100644 index 4de2a75c1..000000000 --- a/server/public/handle_shares_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package public - -import ( - "net/http" - "net/http/httptest" - - "github.com/go-chi/jwtauth/v5" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("handleM3U", func() { - var ds *tests.MockDataStore - var shareRepo *tests.MockShareRepo - var pub *Router - - BeforeEach(func() { - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) - ds = &tests.MockDataStore{} - shareRepo = &tests.MockShareRepo{} - ds.MockedShare = shareRepo - pub = &Router{ds: ds, share: core.NewShare(ds)} - }) - - makeRequest := func(id string) *httptest.ResponseRecorder { - r := httptest.NewRequest("GET", "/public/"+id+"/m3u?%3Aid="+id, nil) - w := httptest.NewRecorder() - pub.handleM3U(w, r) - return w - } - - It("sets the M3U content type", func() { - share := &model.Share{ID: "abc123", Tracks: model.MediaFiles{{ID: "t1", Title: "Track 1"}}} - shareRepo.ID = share.ID - shareRepo.Entity = share - - w := makeRequest("abc123") - - Expect(w.Code).To(Equal(http.StatusOK)) - // Result() has the headers sent at WriteHeader time, unlike w.Header() - Expect(w.Result().Header.Get("Content-Type")).To(Equal("audio/x-mpegurl")) - Expect(w.Body.String()).To(HavePrefix("#EXTM3U")) - }) - - It("returns 404 when the share does not exist", func() { - shareRepo.ID = "other" - shareRepo.Entity = &model.Share{ID: "other"} - - Expect(makeRequest("missing").Code).To(Equal(http.StatusNotFound)) - }) -}) diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 46c7ca210..7d5a836b3 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -3,12 +3,11 @@ package public import ( "errors" "net/http" - "slices" "strconv" "time" "github.com/navidrome/navidrome/core/auth" - streampkg "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" . "github.com/navidrome/navidrome/utils/gg" @@ -26,23 +25,19 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - share, err := pub.ds.Share().Get(ctx, 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().Get(ctx, 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 + 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 + } } - mf, err := pub.ds.MediaFile().Get(ctx, info.id) + mf, err := pub.ds.MediaFile(ctx).Get(info.id) if err != nil { if errors.Is(err, model.ErrNotFound) { http.Error(w, "not found", http.StatusNotFound) @@ -53,21 +48,10 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - // 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 - } - - streamReq := pub.decider.ResolveRequest(ctx, mf, info.format, info.bitrate, 0) - stream, err := pub.streamer.NewStream(ctx, mf, streamReq) + stream, err := pub.streamer.NewStream(ctx, mf, stream.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 @@ -96,26 +80,14 @@ 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.ValidatePublic(tokenString) + c, err := auth.Validate(tokenString) if err != nil { return shareTrackInfo{}, err } 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 965bc7e05..222d5ef1a 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -12,6 +12,7 @@ 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" ) @@ -29,7 +30,7 @@ func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream var _ = Describe("decodeStreamInfo", func() { BeforeEach(func() { - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) }) It("decodes a valid token with all fields", func() { @@ -71,21 +72,24 @@ var _ = Describe("decodeStreamInfo", func() { Expect(err).To(HaveOccurred()) }) - It("rejects a token without a shareID claim", func() { + It("handles tokens without shareID (backward compat)", func() { claims := auth.Claims{ID: "mf-123", Format: "opus"} token, _ := auth.CreatePublicToken(claims) - _, err := decodeStreamInfo(token) - Expect(err).To(HaveOccurred()) + 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()) }) }) var _ = Describe("encodeMediafileShare", func() { BeforeEach(func() { - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) }) It("includes the share ID in the token", func() { - exp := new(time.Now().Add(time.Hour)) + exp := P(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) @@ -107,20 +111,18 @@ var _ = Describe("encodeMediafileShare", func() { }) var _ = Describe("handleStream", func() { - var ctx context.Context var ds *tests.MockDataStore var shareRepo *tests.MockShareRepo var streamer *mockStreamer var pub *Router BeforeEach(func() { - ctx = GinkgoT().Context() - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) - ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + ds = &tests.MockDataStore{} shareRepo = &tests.MockShareRepo{} ds.MockedShare = shareRepo streamer = &mockStreamer{} - pub = &Router{ds: ds, streamer: streamer, decider: stream.NewTranscodeDecider(ds, tests.NewMockFFmpeg(""))} + pub = &Router{ds: ds, streamer: streamer} }) makeRequest := func(token string) *httptest.ResponseRecorder { @@ -130,85 +132,19 @@ var _ = Describe("handleStream", func() { return w } - 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(ctx, &owner)).To(Succeed()) - ds.MockedUser = userRepo - mfRepo := tests.CreateMockMediaFileRepo() - 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"}, - ) + shareRepo.ID = "share123" + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}}) + ds.MockedMediaFile = mfRepo claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) makeRequest(token) Expect(streamer.called).To(BeTrue()) - }) - - It("resolves the full stream request like the Subsonic endpoint, so transcodes share the cache", func() { - mf := model.MediaFile{ID: "mf-123", Suffix: "flac", BitRate: 1500, SampleRate: 44100, BitDepth: new(24), Channels: 2} - shareOwnedBy(model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, mf) - - claims := auth.Claims{ID: "mf-123", Format: "opus", BitRate: 128, ShareID: "share123"} - token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) - makeRequest(token) - - Expect(streamer.req).To(Equal(stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2})) - }) - - 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(ctx, &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()) + Expect(streamer.req.Format).To(Equal("mp3")) + Expect(streamer.req.BitRate).To(Equal(192)) }) It("returns 400 for an expired token", func() { @@ -228,7 +164,8 @@ var _ = Describe("handleStream", func() { It("returns 410 when share has been set to expired", func() { shareRepo.ID = "share123" - shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))} + expired := time.Now().Add(-time.Hour) + shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired} claims := auth.Claims{ID: "mf-123", ShareID: "share123"} token, _ := auth.CreatePublicToken(claims) @@ -244,12 +181,12 @@ var _ = Describe("handleStream", func() { Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) - It("returns 400 for tokens without a shareID", func() { + It("skips share check for tokens without shareID (backward compat)", func() { claims := auth.Claims{ID: "mf-123"} token, _ := auth.CreatePublicToken(claims) w := makeRequest(token) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(streamer.called).To(BeFalse()) + // Should get past share check, then fail on media file lookup (no mock data) + Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 400 for an invalid token", func() { diff --git a/server/public/public.go b/server/public/public.go index 8239ef927..18867e1c4 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -1,7 +1,6 @@ package public import ( - "context" "net/http" "path" @@ -21,15 +20,14 @@ type Router struct { http.Handler artwork artwork.Artwork streamer stream.MediaStreamer - decider stream.TranscodeDecider archiver core.Archiver share core.Share assetsHandler http.Handler ds model.DataStore } -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, decider stream.TranscodeDecider, share core.Share, archiver core.Archiver) *Router { - p := &Router{ds: ds, artwork: artwork, streamer: streamer, decider: decider, share: share, archiver: archiver} +func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router { + p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver} shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic) p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) p.Handler = p.routes() @@ -61,7 +59,7 @@ func (pub *Router) routes() http.Handler { return r } -func ShareURL(ctx context.Context, id string) string { +func ShareURL(r *http.Request, id string) string { uri := path.Join(consts.URLPathPublic, id) - return publicurl.PublicURL(ctx, uri, nil) + return publicurl.PublicURL(r, uri, nil) } diff --git a/server/serve_index.go b/server/serve_index.go index 167197403..13fa4a9ce 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -14,7 +14,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/mime" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" @@ -32,7 +31,7 @@ func IndexWithShare(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.H // Injects the config in the `index.html` template func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - c, err := ds.User().CountAll(r.Context()) + c, err := ds.User(r.Context()).CountAll() firstTime := c == 0 && err == nil t, err := getIndexTemplate(r, fs) @@ -58,8 +57,6 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "uiSearchDebounceMs": conf.Server.UISearchDebounceMs, "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, - "pidAlbum": conf.Server.PID.Album, - "pidTrack": conf.Server.PID.Track, "enableNowPlaying": conf.Server.EnableNowPlaying, "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, @@ -78,7 +75,6 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "listenBrainzEnabled": conf.Server.ListenBrainz.Enabled, "enableExternalServices": conf.Server.EnableExternalServices, "enableReplayGain": conf.Server.EnableReplayGain, - "enableQuickConnect": quickconnect.Enabled(), "defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat, "separator": string(os.PathSeparator), "enableInspect": conf.Server.Inspect.Enabled, @@ -111,7 +107,6 @@ 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/serve_index_test.go b/server/serve_index_test.go index 277513768..78f3873b8 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -1,7 +1,6 @@ package server import ( - "context" "encoding/json" "fmt" "net/http" @@ -89,8 +88,6 @@ var _ = Describe("serveIndex", func() { Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)), Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)), Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true), - Entry("pidAlbum", func() { conf.Server.PID.Album = "folder" }, "pidAlbum", "folder"), - Entry("pidTrack", func() { conf.Server.PID.Track = "title" }, "pidTrack", "title"), Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true), @@ -100,8 +97,6 @@ var _ = Describe("serveIndex", func() { Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true), Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true), Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true), - Entry("enableQuickConnect", func() { conf.Server.Jellyfin.Enabled = true; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", true), - Entry("enableQuickConnect without the Jellyfin API", func() { conf.Server.Jellyfin.Enabled = false; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", false), Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true), Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true), Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"), @@ -344,7 +339,7 @@ type mockedUserRepo struct { empty bool } -func (u *mockedUserRepo) CountAll(context.Context, ...model.QueryOptions) (int64, error) { +func (u *mockedUserRepo) CountAll(...model.QueryOptions) (int64, error) { if u.empty { return 0, nil } diff --git a/server/server.go b/server/server.go index 7e7e2f6bd..b05c20cc5 100644 --- a/server/server.go +++ b/server/server.go @@ -17,6 +17,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -204,12 +205,11 @@ func (s *Server) initRoutes() { func (s *Server) mountAuthenticationRoutes() chi.Router { r := s.router return r.Route(path.Join(conf.Server.BasePath, "/auth"), func(r chi.Router) { - r.Use(LimitLoginBody) if conf.Server.AuthRequestLimit > 0 { log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit, "windowLength", conf.Server.AuthWindowLength) - rateLimiter := ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) r.With(rateLimiter).Post("/login", login(s.ds)) } else { log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks") diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 0a0c65ec7..0d82c8be9 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -9,7 +9,7 @@ import ( "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/run" @@ -46,7 +46,7 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { if err != nil { return nil, 0, err } - opts = filter.AlbumsByGenre(genre) + opts = filter.ByGenre(genre) case "byYear": fromYear, err := p.Int("fromYear") if err != nil { @@ -71,14 +71,14 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { opts.Offset = p.IntOr("offset", 0) opts.Max = min(p.IntOr("size", 10), 500) - albums, err := api.ds.Album().GetAll(r.Context(), opts) + albums, err := api.ds.Album(r.Context()).GetAll(opts) if err != nil { log.Error(r, "Error retrieving albums", err) return nil, 0, newError(responses.ErrorGeneric, "internal error") } - count, err := api.ds.Album().CountAll(r.Context(), opts) + count, err := api.ds.Album(r.Context()).CountAll(opts) if err != nil { log.Error(r, "Error counting albums", err) return nil, 0, newError(responses.ErrorGeneric, "internal error") @@ -137,7 +137,7 @@ func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums func() error { artistOpts := filter.ApplyArtistLibraryFilter(filter.ArtistsByStarred(), musicFolderIds) var err error - artists, err = api.ds.Artist().GetAll(ctx, artistOpts) + artists, err = api.ds.Artist(ctx).GetAll(artistOpts) if err != nil { log.Error(r, "Error retrieving starred artists", err) } @@ -147,7 +147,7 @@ func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums func() error { albumOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds) var err error - albums, err = api.ds.Album().GetAll(ctx, albumOpts) + albums, err = api.ds.Album(ctx).GetAll(albumOpts) if err != nil { log.Error(r, "Error retrieving starred albums", err) } @@ -157,7 +157,7 @@ func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums func() error { mediaFileOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds) var err error - mediaFiles, err = api.ds.MediaFile().GetAll(ctx, mediaFileOpts) + mediaFiles, err = api.ds.MediaFile(ctx).GetAll(mediaFileOpts) if err != nil { log.Error(r, "Error retrieving starred mediaFiles", err) } @@ -240,11 +240,10 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - opts := filter.SongsByGenreAndYearRange(genre, fromYear, toYear) + opts := filter.SongsByRandom(genre, fromYear, toYear) opts = filter.ApplyLibraryFilter(opts, musicFolderIds) - opts.Max = size - songs, err := api.ds.MediaFile().GetRandom(r.Context(), opts) + songs, err := api.getSongs(r.Context(), 0, size, opts) if err != nil { log.Error(r, "Error retrieving random songs", err) return nil, err @@ -267,7 +266,7 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - opts := filter.SongsByGenre(genre) + opts := filter.ByGenre(genre) opts = filter.ApplyLibraryFilter(opts, musicFolderIds) ctx := r.Context() @@ -286,5 +285,5 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) func (api *Router) getSongs(ctx context.Context, offset, size int, opts filter.Options) (model.MediaFiles, error) { opts.Offset = offset opts.Max = size - return api.ds.MediaFile().GetAll(ctx, opts) + return api.ds.MediaFile(ctx).GetAll(opts) } diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index c4a8847d4..220376b15 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" @@ -20,11 +21,12 @@ var _ = Describe("Album Lists", func() { var ds model.DataStore var mockRepo *tests.MockAlbumRepo var w *httptest.ResponseRecorder + ctx := log.NewContext(context.TODO()) BeforeEach(func() { ds = &tests.MockDataStore{} auth.Init(ds) - mockRepo = ds.Album().(*tests.MockAlbumRepo) + mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) @@ -234,7 +236,7 @@ var _ = Describe("Album Lists", func() { var mockMediaFileRepo *tests.MockMediaFileRepo BeforeEach(func() { - mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) + mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) }) It("should return random songs", func() { @@ -326,7 +328,7 @@ var _ = Describe("Album Lists", func() { var mockMediaFileRepo *tests.MockMediaFileRepo BeforeEach(func() { - mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) + mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) }) It("should return songs by genre", func() { @@ -420,9 +422,9 @@ var _ = Describe("Album Lists", func() { var mockMediaFileRepo *tests.MockMediaFileRepo BeforeEach(func() { - mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) - mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) - mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) + mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) + mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) }) It("should return starred items", func() { @@ -482,9 +484,9 @@ var _ = Describe("Album Lists", func() { var mockMediaFileRepo *tests.MockMediaFileRepo BeforeEach(func() { - mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) - mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) - mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) + mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) + mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo) }) It("should return starred items in ID3 format", func() { diff --git a/server/subsonic/api.go b/server/subsonic/api.go index fe724741c..1ca364449 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "regexp" - "strconv" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" @@ -107,7 +106,6 @@ func (api *Router) routes() http.Handler { r.Use(getPlayer(api.players)) h(r, "ping", api.Ping) h(r, "getLicense", api.GetLicense) - h(r, "tokenInfo", api.TokenInfo) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) @@ -173,12 +171,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.With(adminOnly), "getUsers", api.GetUsers) + h(r, "getUsers", api.GetUsers) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) h(r, "getScanStatus", api.GetScanStatus) - h(r.With(adminOnly), "startScan", api.StartScan) + h(r, "startScan", api.StartScan) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) @@ -197,13 +195,10 @@ 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) - r.Group(func(r chi.Router) { - r.Use(adminOnly) - h(r, "createInternetRadioStation", api.CreateInternetRadio) - h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) - h(r, "updateInternetRadioStation", api.UpdateInternetRadio) - }) + h(r, "updateInternetRadioStation", api.UpdateInternetRadio) }) if conf.Server.EnableSharing { r.Group(func(r chi.Router) { @@ -304,10 +299,8 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorGeneric, err.Error()) case errors.Is(err, model.ErrNotFound): err = newError(responses.ErrorDataNotFound, "data not found") - case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable): // Subsonic has no code for "read-only resource" + case errors.Is(err, model.ErrNotAuthorized): 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)) } @@ -317,31 +310,15 @@ 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()} - return response + + sendResponse(w, r, 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 @@ -376,9 +353,6 @@ func sendResponseWithStatus(w http.ResponseWriter, r *http.Request, payload *res sendError(w, r, err) return } - if status != 0 { - w.WriteHeader(status) - } if payload.Status == responses.StatusOK { if log.IsGreaterOrEqualTo(log.LevelTrace) { @@ -401,10 +375,6 @@ func sendResponseWithStatus(w http.ResponseWriter, r *http.Request, payload *res } if _, err := w.Write(response); err != nil { //nolint:gosec - 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) - } + log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) } } diff --git a/server/subsonic/api_suite_test.go b/server/subsonic/api_suite_test.go index 485daca58..a83f2f0eb 100644 --- a/server/subsonic/api_suite_test.go +++ b/server/subsonic/api_suite_test.go @@ -1,13 +1,9 @@ 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" @@ -19,16 +15,3 @@ 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 f8d5b6642..f3053c8af 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -1,22 +1,18 @@ 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() { @@ -140,7 +136,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: new(math.Inf(1))} + payload.Song.ReplayGain = responses.ReplayGain{TrackGain: gg.P(math.Inf(1))} q := r.URL.Query() q.Add("f", "json") r.URL.RawQuery = q.Encode() @@ -157,24 +153,6 @@ 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) @@ -190,24 +168,3 @@ 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/auth_limiter.go b/server/subsonic/auth_limiter.go deleted file mode 100644 index 88df6eb50..000000000 --- a/server/subsonic/auth_limiter.go +++ /dev/null @@ -1,134 +0,0 @@ -package subsonic - -import ( - "cmp" - "context" - "hash/maphash" - "sync" - "time" - - "github.com/navidrome/navidrome/consts" -) - -// authLimiter caps failed Subsonic logins per key. Checks run at most `limit` at a time and failures -// are recorded afterwards, so a window admits up to 2*limit-1 guesses and valid requests only wait. -type authLimiter struct { - limit int - window time.Duration - seed maphash.Seed - mu sync.Mutex - keys map[uint64]*authAttempts // hashed, so attacker-chosen usernames cannot bloat memory - lastSweep time.Time -} - -type authAttempts struct { - failures int - start time.Time - slots chan struct{} - refs int -} - -// authSlot is a reserved credential check. A nil slot releases nothing, which is what a disabled -// limiter hands back. -type authSlot struct { - limiter *authLimiter - entry *authAttempts -} - -// newAuthLimiter returns nil when limit is not positive. A nil limiter allows everything. -func newAuthLimiter(limit int, window time.Duration) *authLimiter { - if limit <= 0 { - return nil - } - return &authLimiter{ - limit: limit, - window: cmp.Or(window, consts.DefaultAuthWindowLength), - seed: maphash.MakeSeed(), - keys: map[uint64]*authAttempts{}, - } -} - -// acquire reserves a credential check for key, waiting while other checks for the same key are in -// flight. It only fails when the key already reached `limit` failures in the current window. -func (l *authLimiter) acquire(ctx context.Context, key string) (*authSlot, bool) { - if l == nil { - return nil, true - } - a, ok := l.reserve(key) - if !ok { - return nil, false - } - - select { - case a.slots <- struct{}{}: - case <-ctx.Done(): - l.unref(a) - return nil, false - } - - l.mu.Lock() - blocked := a.failures >= l.limit - if blocked { - a.refs-- - } - l.mu.Unlock() - if blocked { - <-a.slots - return nil, false - } - return &authSlot{limiter: l, entry: a}, true -} - -func (l *authLimiter) reserve(key string) (*authAttempts, bool) { - now := time.Now() - l.mu.Lock() - defer l.mu.Unlock() - l.sweep(now) - - h := maphash.String(l.seed, key) - a := l.keys[h] - switch { - case a == nil: - a = &authAttempts{start: now, slots: make(chan struct{}, l.limit)} - l.keys[h] = a - case now.Sub(a.start) >= l.window: - a.failures, a.start = 0, now - } - if a.failures >= l.limit { - return nil, false - } - a.refs++ - return a, true -} - -func (l *authLimiter) unref(a *authAttempts) { - l.mu.Lock() - a.refs-- - l.mu.Unlock() -} - -func (s *authSlot) release(failed bool) { - if s == nil { - return - } - s.limiter.mu.Lock() - if failed { - s.entry.failures++ - } - s.entry.refs-- - s.limiter.mu.Unlock() - <-s.entry.slots -} - -// sweep drops idle expired keys once per window, so memory is bounded by recent attempts. -func (l *authLimiter) sweep(now time.Time) { - if now.Sub(l.lastSweep) < l.window { - return - } - for h, a := range l.keys { - if a.refs == 0 && now.Sub(a.start) >= l.window { - delete(l.keys, h) - } - } - l.lastSweep = now -} diff --git a/server/subsonic/auth_limiter_test.go b/server/subsonic/auth_limiter_test.go deleted file mode 100644 index 66e1a594f..000000000 --- a/server/subsonic/auth_limiter_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package subsonic - -import ( - "context" - "sync" - "sync/atomic" - "testing" - "testing/synctest" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("authLimiter", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - }) - - acquire := func(l *authLimiter, key string) (*authSlot, bool) { - GinkgoHelper() - return l.acquire(ctx, key) - } - - It("blocks a key after the configured number of failures", func() { - l := newAuthLimiter(2, time.Minute) - for range 2 { - slot, ok := acquire(l, "k") - Expect(ok).To(BeTrue()) - slot.release(true) - } - - _, ok := acquire(l, "k") - Expect(ok).To(BeFalse()) - }) - - It("never counts successful checks", func() { - l := newAuthLimiter(2, time.Minute) - for range 50 { - slot, ok := acquire(l, "k") - Expect(ok).To(BeTrue()) - slot.release(false) - } - }) - - It("keeps keys independent", func() { - l := newAuthLimiter(1, time.Minute) - slot, _ := acquire(l, "a") - slot.release(true) - _, ok := acquire(l, "a") - Expect(ok).To(BeFalse()) - - _, ok = acquire(l, "b") - Expect(ok).To(BeTrue()) - }) - - It("waits for an in-flight check instead of failing the request", func() { - l := newAuthLimiter(1, time.Minute) - held, ok := acquire(l, "k") - Expect(ok).To(BeTrue()) - - waiting := make(chan bool, 1) - go func() { - slot, ok := l.acquire(ctx, "k") - slot.release(false) - waiting <- ok - }() - Consistently(waiting, 50*time.Millisecond).ShouldNot(Receive()) - - held.release(false) - Eventually(waiting).Should(Receive(BeTrue())) - }) - - It("stops waiting when the request is canceled", func() { - l := newAuthLimiter(1, time.Minute) - held, _ := acquire(l, "k") - DeferCleanup(func() { held.release(false) }) - - canceled, cancel := context.WithCancel(context.Background()) - cancel() - _, ok := l.acquire(canceled, "k") - Expect(ok).To(BeFalse()) - }) - - It("does not let concurrent guesses overshoot the limit", func() { - l := newAuthLimiter(5, time.Minute) - hold := make(chan struct{}) - var checks atomic.Int32 - var wg sync.WaitGroup - for range 50 { - wg.Go(func() { - slot, ok := l.acquire(ctx, "k") - if !ok { - return - } - checks.Add(1) - <-hold - slot.release(true) - }) - } - - Eventually(checks.Load).Should(Equal(int32(5))) - Consistently(checks.Load, 100*time.Millisecond).Should(Equal(int32(5))) - close(hold) - wg.Wait() - - _, ok := acquire(l, "k") - Expect(ok).To(BeFalse()) - }) - - It("allows everything when the limit is disabled", func() { - l := newAuthLimiter(0, time.Minute) - for range 10 { - slot, ok := acquire(l, "k") - Expect(ok).To(BeTrue()) - slot.release(true) - } - }) -}) - -// testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give. -func TestAuthLimiterWindow(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - ctx := context.Background() - l := newAuthLimiter(1, 20*time.Second) - for _, key := range []string{"a", "b"} { - slot, ok := l.acquire(ctx, key) - g.Expect(ok).To(BeTrue()) - slot.release(true) - } - _, ok := l.acquire(ctx, "a") - g.Expect(ok).To(BeFalse()) - - time.Sleep(20 * time.Second) - slot, ok := l.acquire(ctx, "a") - g.Expect(ok).To(BeTrue()) - slot.release(false) - g.Expect(l.keys).To(HaveLen(1), "expired keys must be dropped") - }) -} diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index 6a4c4962d..337712750 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -15,8 +15,8 @@ import ( func (api *Router) GetBookmarks(r *http.Request) (*responses.Subsonic, error) { user, _ := request.UserFrom(r.Context()) - repo := api.ds.MediaFile() - bookmarks, err := repo.GetBookmarks(r.Context()) + repo := api.ds.MediaFile(r.Context()) + bookmarks, err := repo.GetBookmarks() if err != nil { return nil, err } @@ -46,16 +46,8 @@ func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) comment, _ := p.String("comment") position := p.Int64Or("position", 0) - repo := api.ds.MediaFile() - ok, err := repo.Exists(r.Context(), id) - if err != nil { - return nil, err - } - if !ok { - return nil, newError(responses.ErrorDataNotFound, "Song not found") - } - - err = repo.AddBookmark(r.Context(), id, comment, position) + repo := api.ds.MediaFile(r.Context()) + err = repo.AddBookmark(id, comment, position) if err != nil { return nil, err } @@ -69,8 +61,8 @@ func (api *Router) DeleteBookmark(r *http.Request) (*responses.Subsonic, error) return nil, err } - repo := api.ds.MediaFile() - err = repo.DeleteBookmark(r.Context(), id) + repo := api.ds.MediaFile(r.Context()) + err = repo.DeleteBookmark(id) if err != nil { return nil, err } @@ -80,8 +72,8 @@ func (api *Router) DeleteBookmark(r *http.Request) (*responses.Subsonic, error) func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { user, _ := request.UserFrom(r.Context()) - repo := api.ds.PlayQueue() - pq, err := repo.RetrieveWithMediaFiles(r.Context(), user.ID) + repo := api.ds.PlayQueue(r.Context()) + pq, err := repo.RetrieveWithMediaFiles(user.ID) if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, err } @@ -111,7 +103,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids := p.Strings("id") + ids, _ := p.Strings("id") currentID, _ := p.String("current") position := p.Int64Or("position", 0) @@ -140,8 +132,8 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { UpdatedAt: time.Time{}, } - repo := api.ds.PlayQueue() - err := repo.Store(r.Context(), pq) + repo := api.ds.PlayQueue(r.Context()) + err := repo.Store(pq) if err != nil { return nil, err } @@ -151,8 +143,8 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) { user, _ := request.UserFrom(r.Context()) - repo := api.ds.PlayQueue() - pq, err := repo.RetrieveWithMediaFiles(r.Context(), user.ID) + repo := api.ds.PlayQueue(r.Context()) + pq, err := repo.RetrieveWithMediaFiles(user.ID) if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, err } @@ -184,7 +176,7 @@ func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, er func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids := p.Strings("id") + ids, _ := p.Strings("id") position := p.Int64Or("position", 0) @@ -215,8 +207,8 @@ func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, e UpdatedAt: time.Time{}, } - repo := api.ds.PlayQueue() - err = repo.Store(r.Context(), pq) + repo := api.ds.PlayQueue(r.Context()) + err = repo.Store(pq) if err != nil { return nil, err } diff --git a/server/subsonic/bookmarks_test.go b/server/subsonic/bookmarks_test.go deleted file mode 100644 index 387ba6ab2..000000000 --- a/server/subsonic/bookmarks_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package subsonic - -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Bookmarks", func() { - var router *Router - var ds *tests.MockDataStore - var mfRepo *tests.MockMediaFileRepo - var ctx context.Context - - BeforeEach(func() { - ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - ctx = request.WithUser(context.Background(), model.User{ID: "u1", UserName: "u1"}) - mfRepo = ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "visible"}}) - }) - - Describe("CreateBookmark", func() { - It("rejects an id the user cannot read", func() { - r := newGetRequest("id=hidden", "position=1").WithContext(ctx) - - _, err := router.CreateBookmark(r) - - Expect(err).To(HaveOccurred()) - Expect(mapToSubsonicError(err).code).To(Equal(responses.ErrorDataNotFound)) - Expect(mfRepo.BookmarksAdded).To(BeEmpty()) - }) - - It("accepts an id the user can read", func() { - r := newGetRequest("id=visible", "position=1").WithContext(ctx) - - _, err := router.CreateBookmark(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mfRepo.BookmarksAdded).To(ConsistOf("visible")) - }) - }) -}) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index d74468940..5b9c4f3c9 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -11,7 +11,7 @@ import ( "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" @@ -33,7 +33,7 @@ func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) { ctx := r.Context() - lastScanStr, err := api.ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") + lastScanStr, err := api.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") if err != nil { log.Error(ctx, "Error retrieving last scan start time", err) return nil, 0, err @@ -45,7 +45,7 @@ func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time var indexes model.ArtistIndexes if lastScan.After(ifModifiedSince) { - indexes, err = api.ds.Artist().GetIndex(ctx, false, libIds, model.RoleAlbumArtist) + indexes, err = api.ds.Artist(ctx).GetIndex(false, libIds, model.RoleAlbumArtist) if err != nil { log.Error(ctx, "Error retrieving Indexes", err) return nil, 0, err @@ -167,7 +167,7 @@ func (api *Router) GetArtist(r *http.Request) (*responses.Subsonic, error) { id, _ := p.String("id") ctx := r.Context() - artist, err := api.ds.Artist().Get(ctx, id) + artist, err := api.ds.Artist(ctx).Get(id) if errors.Is(err, model.ErrNotFound) { log.Error(ctx, "Requested ArtistID not found ", "id", id) return nil, newError(responses.ErrorDataNotFound, "Artist not found") @@ -191,7 +191,7 @@ func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - album, err := api.ds.Album().Get(ctx, id) + album, err := api.ds.Album(ctx).Get(id) if errors.Is(err, model.ErrNotFound) { log.Error(ctx, "Requested AlbumID not found ", "id", id) return nil, newError(responses.ErrorDataNotFound, "Album not found") @@ -201,7 +201,7 @@ func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) { return nil, err } - mfs, err := api.ds.MediaFile().GetAll(ctx, filter.SongsByAlbum(id)) + mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(id)) if err != nil { log.Error(ctx, "Error retrieving tracks from album", "id", id, "name", album.Name, err) return nil, err @@ -230,11 +230,9 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { response := newResponse() response.AlbumInfo = &responses.AlbumInfo{} response.AlbumInfo.Notes = album.Description - if !album.ImageAbsent { - response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 300) - response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 600) - response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 1200) - } + response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) + response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) + response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) response.AlbumInfo.LastFmUrl = album.ExternalUrl response.AlbumInfo.MusicBrainzID = album.MbzAlbumID @@ -247,7 +245,7 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { id, _ := p.String("id") ctx := r.Context() - mf, err := api.ds.MediaFile().Get(ctx, id) + mf, err := api.ds.MediaFile(ctx).Get(id) if errors.Is(err, model.ErrNotFound) { log.Error(r, "Requested MediaFileID not found ", "id", id) return nil, newError(responses.ErrorDataNotFound, "Song not found") @@ -258,13 +256,14 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { } response := newResponse() - response.Song = new(childFromMediaFile(ctx, *mf)) + child := childFromMediaFile(ctx, *mf) + response.Song = &child return response, nil } func (api *Router) GetGenres(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - genres, err := api.ds.Genre().GetAll(ctx, model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"}) + genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"}) if err != nil { log.Error(r, err) return nil, err @@ -297,11 +296,9 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m base := responses.ArtistInfoBase{} base.Biography = artist.Biography - if !artist.ImageAbsent { - base.SmallImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 300) - base.MediumImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) - base.LargeImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 1200) - } + base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) + base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) + base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) base.LastFmUrl = artist.ExternalUrl base.MusicBrainzID = artist.MbzArtistID @@ -385,14 +382,13 @@ func (api *Router) GetSimilarSongs2(r *http.Request) (*responses.Subsonic, error func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() p := req.Params(r) - id, idErr := p.String("id") artist, err := p.String("artist") - if err != nil && idErr != nil { + if err != nil { return nil, err } count := p.IntOr("count", 50) - songs, err := api.provider.TopSongs(ctx, artist, id, count) + songs, err := api.provider.TopSongs(ctx, artist, count) if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, err } @@ -421,7 +417,7 @@ func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artis dir.Starred = artist.StarredAt } - albums, err := api.ds.Album().GetAll(ctx, filter.AlbumsByArtistID(artist.ID)) + albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID)) if err != nil { return nil, err } @@ -435,7 +431,7 @@ func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*response a := &responses.ArtistWithAlbumsID3{} a.ArtistID3 = toArtistID3(r, *artist) - albums, err := api.ds.Album().GetAll(ctx, filter.AlbumsByArtistID(artist.ID)) + albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID)) if err != nil { return nil, err } @@ -458,12 +454,12 @@ func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) dir.AverageRating = album.AverageRating } dir.SongCount = int32(album.SongCount) - dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) + dir.CoverArt = album.CoverArtID().String() if album.Starred { dir.Starred = album.StarredAt } - mfs, err := api.ds.MediaFile().GetAll(ctx, filter.SongsByAlbum(album.ID)) + mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(album.ID)) if err != nil { return nil, err } diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index 71d758cfd..b8f510aed 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -6,7 +6,6 @@ import ( "net/http/httptest" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" @@ -83,14 +82,14 @@ var _ = Describe("Browsing", func() { ctx = contextWithUser(ctx, "user-id", 2, 3) // Setup minimal mock library data for working tests - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{ {ID: 2, Name: "Test Library 2", Path: "/music/library2"}, {ID: 3, Name: "Test Library 3", Path: "/music/library3"}, }) // Setup mock artist data - mockArtistRepo := ds.Artist().(*tests.MockArtistRepo) + mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo) mockArtistRepo.SetData(model.Artists{ {ID: "1", Name: "Test Artist 1"}, {ID: "2", Name: "Test Artist 2"}, @@ -132,14 +131,14 @@ var _ = Describe("Browsing", func() { ctx = contextWithUser(ctx, "user-id", 1, 2) // Setup minimal mock library data for working tests - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) + mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{ {ID: 1, Name: "Test Library 1", Path: "/music/library1"}, {ID: 2, Name: "Test Library 2", Path: "/music/library2"}, }) // Setup mock artist data - mockArtistRepo := ds.Artist().(*tests.MockArtistRepo) + mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo) mockArtistRepo.SetData(model.Artists{ {ID: "1", Name: "Test Artist 1"}, {ID: "2", Name: "Test Artist 2"}, @@ -158,57 +157,4 @@ var _ = Describe("Browsing", func() { Expect(response.Artist).ToNot(BeNil()) }) }) - - Describe("GetAlbumInfo", func() { - It("emits image URLs when the album artwork is unresolved", func() { - api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1"}} - r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) - resp, err := api.GetAlbumInfo(r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.AlbumInfo.SmallImageUrl).ToNot(BeEmpty()) - Expect(resp.AlbumInfo.LargeImageUrl).ToNot(BeEmpty()) - }) - It("omits image URLs when the album artwork is known absent", func() { - api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}}} - r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) - resp, err := api.GetAlbumInfo(r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.AlbumInfo.SmallImageUrl).To(BeEmpty()) - Expect(resp.AlbumInfo.MediumImageUrl).To(BeEmpty()) - Expect(resp.AlbumInfo.LargeImageUrl).To(BeEmpty()) - }) - }) - - Describe("GetArtistInfo", func() { - It("emits image URLs when the artist artwork is unresolved", func() { - api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1"}} - r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) - resp, err := api.GetArtistInfo(r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.ArtistInfo.SmallImageUrl).ToNot(BeEmpty()) - }) - It("omits image URLs when the artist artwork is known absent", func() { - api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}}} - r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) - resp, err := api.GetArtistInfo(r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.ArtistInfo.SmallImageUrl).To(BeEmpty()) - Expect(resp.ArtistInfo.MediumImageUrl).To(BeEmpty()) - Expect(resp.ArtistInfo.LargeImageUrl).To(BeEmpty()) - }) - }) }) - -type fakeInfoProvider struct { - external.Provider - album *model.Album - artist *model.Artist -} - -func (f *fakeInfoProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { - return f.album, nil -} - -func (f *fakeInfoProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { - return f.artist, nil -} diff --git a/server/subsonic/e2e/subsonic_apikey_test.go b/server/subsonic/e2e/subsonic_apikey_test.go deleted file mode 100644 index bb263c15d..000000000 --- a/server/subsonic/e2e/subsonic_apikey_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package e2e - -import ( - "net/http/httptest" - "net/url" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("API key authentication", func() { - var key string - - BeforeEach(func() { - setupTestDB() - userCtx := request.WithUser(ctx, regularUser) - player := &model.Player{ID: "apikey-player", Name: "Phone", UserId: regularUser.ID, Client: "test-client"} - Expect(ds.Player().Put(userCtx, player)).To(Succeed()) - key = "nds_0123456789abcdefghijkl" - Expect(ds.Player().SetAPIKey(userCtx, player.ID, key)).To(Succeed()) - }) - - doKeyReq := func(endpoint, apiKey string) *responses.Subsonic { - q := url.Values{"apiKey": {apiKey}, "v": {"1.16.1"}, "c": {"test-client"}, "f": {"json"}} - w := httptest.NewRecorder() - router.ServeHTTP(w, httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil)) - return parseJSONResponse(w) - } - - It("authenticates ping with only the key", func() { - resp := doKeyReq("ping", key) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("reports the key owner in tokenInfo", func() { - resp := doKeyReq("tokenInfo", key) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TokenInfo).ToNot(BeNil()) - Expect(resp.TokenInfo.Username).To(Equal(regularUser.UserName)) - }) - - It("rejects an unknown key with error 44", func() { - resp := doKeyReq("ping", "nds_unknown") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(int32(44))) - }) -}) diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go deleted file mode 100644 index 530588759..000000000 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package e2e - -import ( - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "time" - - "github.com/Masterminds/squirrel" - _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor - "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/agents" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/lyrics" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playback" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/scrobbler" - _ "github.com/navidrome/navidrome/core/storage/local" // registers the "file" storage scheme - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/scanner" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/public" - "github.com/navidrome/navidrome/server/subsonic" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/tests/harness" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.senan.xyz/taglib" -) - -// The serving path streams folder-backed originals via os.Open, which a fake FS cannot back, so -// this suite scans a small REAL on-disk library and drives the real worker and artwork.Artwork. -var _ = Describe("Artwork Serving", Ordered, func() { - var ( - artRouter *subsonic.Router - pubRouter *public.Router - artSvc artwork.Artwork - worker *artwork.Worker - artfulID string - artlessID string - artfulHash string - placeholder []byte - ) - - albumCoverInList := func(albumID string) string { - GinkgoHelper() - resp := doReq("getAlbumList2", "type", "alphabeticalByName", "size", "500") - Expect(resp.AlbumList2).ToNot(BeNil()) - for _, al := range resp.AlbumList2.Album { - if al.Id == albumID { - return al.CoverArt - } - } - Fail("album " + albumID + " not found in getAlbumList2") - return "" - } - - getCover := func(params ...string) *httptest.ResponseRecorder { - GinkgoHelper() - w := httptest.NewRecorder() - r := buildReq(adminUser, "getCoverArt", params...) - artRouter.ServeHTTP(w, r) - return w - } - - BeforeAll(func() { - DeferCleanup(configtest.SetupConfig()) - DeferCleanup(func() { Eventually(scanner.IsScanning).Should(BeFalse()) }) - ctx = request.WithUser(GinkgoT().Context(), adminUser) - - musicDir := GinkgoT().TempDir() - writeArtworkTrack(musicDir, "Artful Artist", "Artful Album", "01 - Come Together", true) - writeArtworkTrack(musicDir, "Artless Artist", "Artless Album", "01 - Lonely", false) - - conf.Server.MusicFolder = musicDir - conf.Server.DevExternalScanner = false - conf.Server.DevEnableMediaFileProbe = false - conf.Server.CoverArtPriority = "cover.jpg" - conf.Server.ArtistArtPriority = "artist.png" // offline: artists resolve absent, out of scope here - conf.Server.EnableMediaFileCoverArt = false - conf.Server.DevArtworkWorkerConcurrency = 1 - conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) - conf.Server.EnableSharing = true - conf.Server.DevArtworkMaxRequests = 100 - conf.Server.DevArtworkThrottleBacklogLimit = 100 - conf.Server.DevArtworkThrottleBacklogTimeout = time.Minute - - goldenDB.Restore() - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(ds) - - // Re-scanning already-populated data deadlocks the parallel album-refresh phase at the - // harness's MaxOpenConns=1, so wipe the golden content and import this library fresh. - wipeScannedContent() - artLib := model.Library{Name: "Artwork Library", Path: musicDir} - Expect(ds.Library().Put(ctx, &artLib)).To(Succeed()) - Expect(ds.User().SetUserLibraries(ctx, adminUser.ID, []int{artLib.ID})).To(Succeed()) - - s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - _, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - // Focus the worker on the entities under test. - _, err = db.Db().Exec("DELETE FROM artwork_queue") - Expect(err).ToNot(HaveOccurred()) - - artfulID = albumIDByName("Artful Album") - artlessID = albumIDByName("Artless Album") - - ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) - Expect(err).ToNot(HaveOccurred()) - placeholder, err = io.ReadAll(ph) - Expect(err).ToNot(HaveOccurred()) - _ = ph.Close() - - store := artwork.NewImageStore(GinkgoT().TempDir()) - imgCache := newDummyImageCache(ctx) - ffm := harness.NoopFFmpeg{} - artSvc = artwork.NewArtwork(ds, imgCache, store, ffm) - worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) - - artRouter = buildArtworkRouter(artSvc) - router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router - pubRouter = public.New(ds, artSvc, streamerSpy, stream.NewTranscodeDecider(ds, ffm), core.NewShare(ds), noopArchiver{}) - }) - - It("emits a bare optimistic coverArt id before the queue is drained", func() { - Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID)) - Expect(albumCoverInList(artlessID)).To(Equal("al-" + artlessID)) - }) - - It("drains the queue: folder art is acquired, the artless album settles absent", func() { - // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. - for _, id := range []string{artfulID, artlessID} { - Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{ - ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: id, - ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump, - })).To(Succeed()) - } - runWorkerUntil(ctx, worker, func() bool { - found, err := ds.Artwork().GetItemArtwork(ctx, model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) - if err != nil || found.Hash == "" { - return false - } - absent, err := ds.Artwork().GetItemArtwork(ctx, model.KindAlbumArtwork, artlessID, model.ImageTypePrimary) - return err == nil && absent.Hash == "" - }) - ia, err := ds.Artwork().GetItemArtwork(ctx, model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - artfulHash = ia.Hash - Expect(artfulHash).ToNot(BeEmpty()) - }) - - It("promotes the list id to the hash suffix and omits the absent album's coverArt", func() { - Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID + "_" + artfulHash)) - Expect(albumCoverInList(artlessID)).To(BeEmpty()) - }) - - It("serves the found album's bytes with revalidation headers for a bare id", func() { - w := getCover("id", "al-"+artfulID) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) - Expect(w.Header().Get("ETag")).To(Equal(`"` + artfulHash + `"`)) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) - }) - - It("serves the found album immutably when the exact hash is requested", func() { - w := getCover("id", "al-"+artfulID+"_"+artfulHash) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) - }) - - It("returns 304 with an empty body when the client already holds the hash", func() { - w := httptest.NewRecorder() - r := buildReq(adminUser, "getCoverArt", "id", "al-"+artfulID) - r.Header.Set("If-None-Match", `"`+artfulHash+`"`) - artRouter.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusNotModified)) - Expect(w.Body.Bytes()).To(BeEmpty()) - }) - - It("serves the placeholder with no-store for an absent album", func() { - w := getCover("id", "al-"+artlessID) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Bytes()).To(Equal(placeholder)) - Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) - }) - - // Only adminUser was granted this library, so regularUser must get the placeholder even - // though the artwork state and bytes are perfectly servable by id. - It("serves the placeholder for an album in a library the caller cannot see", func() { - w := httptest.NewRecorder() - artRouter.ServeHTTP(w, buildReq(regularUser, "getCoverArt", "id", "al-"+artfulID)) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Bytes()).To(Equal(placeholder), "must not leak the real cover") - Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) - - Expect(getCover("id", "al-"+artfulID).Body.Bytes()).ToNot(Equal(placeholder)) - }) - - // An id naming no entity is a different answer from an album with no art: no placeholder. - It("answers error 70 for an id that matches no entity", func() { - w := getCover("id", "al-nosuchalbum") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(ContainSubstring(`"code":70`)) - Expect(w.Body.Bytes()).ToNot(Equal(placeholder)) - }) - - It("serves /share/img immutably for a JWT whose payload carries the hash", func() { - token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), - auth.Claims{ID: "al-" + artfulID + "_" + artfulHash}) - Expect(err).ToNot(HaveOccurred()) - - w := httptest.NewRecorder() - pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) - }) - - It("returns 404 from /share/img for an absent entity", func() { - token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), - auth.Claims{ID: "al-" + artlessID}) - Expect(err).ToNot(HaveOccurred()) - - w := httptest.NewRecorder() - pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) -}) - -// buildArtworkRouter mirrors setupTestDB's Subsonic wiring but with the real artwork.Artwork. -func buildArtworkRouter(art artwork.Artwork) *subsonic.Router { - decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) - s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - return subsonic.New( - ds, art, streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, s, - events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), - scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), - playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, - ) -} - -// wipeScannedContent clears all scanned content so the next scan is a clean import. FKs are -// disabled for the bulk delete, mirroring harness.Restore. -func wipeScannedContent() { - GinkgoHelper() - _, err := db.Db().Exec("PRAGMA foreign_keys = OFF") - Expect(err).ToNot(HaveOccurred()) - for _, t := range []string{"media_file", "album", "artist", "folder", "item_artwork", "artwork_queue", "artwork", "library"} { - _, err = db.Db().Exec(`DELETE FROM "` + t + `"`) - Expect(err).ToNot(HaveOccurred(), "wiping %s", t) - } - _, err = db.Db().Exec("PRAGMA foreign_keys = ON") - Expect(err).ToNot(HaveOccurred()) -} - -func albumIDByName(name string) string { - GinkgoHelper() - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).To(HaveLen(1), "expected exactly one album named %q", name) - return albums[0].ID -} - -// Distinct ALBUM/ALBUMARTIST tags keep the generated albums from merging into one. -func writeArtworkTrack(root, artist, album, title string, withCover bool) { - GinkgoHelper() - dir := filepath.Join(root, artist, album) - Expect(os.MkdirAll(dir, 0o755)).To(Succeed()) - mp3 := filepath.Join(dir, title+".mp3") - Expect(os.WriteFile(mp3, readArtworkFixture("test.mp3"), 0o600)).To(Succeed()) - Expect(taglib.WriteTags(mp3, map[string][]string{ - "ALBUM": {album}, "ALBUMARTIST": {artist}, "ARTIST": {artist}, "TITLE": {title}, - }, taglib.Clear)).To(Succeed()) - if withCover { - Expect(os.WriteFile(filepath.Join(dir, "cover.jpg"), readArtworkFixture("cover.jpg"), 0o600)).To(Succeed()) - } -} - -func readArtworkFixture(name string) []byte { - GinkgoHelper() - data, err := os.ReadFile(filepath.Join("tests", "fixtures", "artist", "an-album", name)) - Expect(err).ToNot(HaveOccurred()) - return data -} - -// size=0 requests stream originals and never invoke the reader, so this resize cache only has -// to satisfy the constructor. -func newDummyImageCache(ctx context.Context) cache.FileCache { - GinkgoHelper() - c := cache.NewFileCache("SubsonicArtworkE2E", "100MB", "images", 0, - func(context.Context, cache.Item) (io.Reader, error) { - return nil, errors.New("resize not exercised in subsonic artwork e2e") - }) - Eventually(func() bool { return c.Available(ctx) }, 10*time.Second).Should(BeTrue()) - return c -} - -func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { - GinkgoHelper() - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { done <- worker.Run(runCtx) }() - Eventually(until, 10*time.Second, 20*time.Millisecond).Should(BeTrue()) - cancel() - Eventually(done, 2*time.Second).Should(Receive(BeNil())) -} diff --git a/server/subsonic/e2e/subsonic_lyrics_test.go b/server/subsonic/e2e/subsonic_lyrics_test.go deleted file mode 100644 index da8d513a3..000000000 --- a/server/subsonic/e2e/subsonic_lyrics_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package e2e - -import ( - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Lyrics endpoints", func() { - BeforeEach(func() { - setupTestDB() - }) - - // songID resolves a track title to its Subsonic ID via search3. - songID := func(title string) string { - resp := doReq("search3", "query", title, "songCount", "1", "artistCount", "0", "albumCount", "0") - Expect(resp.Status).To(Equal("ok")) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Song).ToNot(BeEmpty(), "expected to find song %q", title) - return resp.SearchResult3.Song[0].Id - } - - // firstLyric extracts the first StructuredLyric from a LyricsList response. - firstLyric := func(list *responses.LyricsList) responses.StructuredLyric { - Expect(list).ToNot(BeNil()) - Expect(list.StructuredLyrics).ToNot(BeEmpty()) - return list.StructuredLyrics[0] - } - - // songLyrics extension v1: getLyricsBySongId without the enhanced parameter - // returns line-level structured lyrics (line[], lang, synced) and must NOT - // emit any v2/enhanced fields — no cueLine, kind, or agents — even for - // formats that carry word-level timing (ELRC, Lyricsfile YAML). - Describe("getLyricsBySongId v1 (line-level, not enhanced)", func() { - DescribeTable("returns line-level lyrics without enhanced fields", - func(title string, wantSynced bool, wantLang string) { - resp := doReq("getLyricsBySongId", "id", songID(title)) - Expect(resp.Status).To(Equal("ok")) - got := firstLyric(resp.LyricsList) - - Expect(got.Synced).To(Equal(wantSynced)) - Expect(got.Lang).To(Equal(wantLang)) - Expect(got.Line).ToNot(BeEmpty()) - Expect(got.Line[0].Value).To(Equal(firstFixtureLine)) - - // v1 must not expose any enhanced (v2) data. - Expect(got.CueLine).To(BeEmpty()) - Expect(got.Kind).To(BeEmpty()) - Expect(got.Agents).To(BeEmpty()) - }, - // "xxx" is the ISO 639-2 code for "no language specified"; the .lrc/.elrc - // fixtures declare [lang:eng], the .ttml declares xml:lang, the .yaml sets - // language: eng, while .srt carries no language and the embedded plain - // text has none — so each format exercises a different language path. - Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true, "eng"), - Entry("embedded plain text", "Embedded Plain", false, "xxx"), - Entry("embedded TTML", "Embedded TTML", true, "eng"), - Entry("LRC sidecar", "Sidecar LRC", true, "eng"), - Entry("SRT sidecar", "Sidecar SRT", true, "xxx"), - Entry("YAML sidecar (word-level)", "Sidecar YAML", true, "eng"), - ) - }) - - // songLyrics extension v2: getLyricsBySongId?enhanced=true opts in to - // word/syllable-level timing (cueLine) and the kind classification. Every - // format gains kind="main" for a single untyped lyric layer; only formats - // that carry word-level timing (ELRC, TTML word spans, Lyricsfile YAML) - // surface a cueLine. Line-level formats (LRC, SRT, plain) still yield none. - Describe("getLyricsBySongId v2 (enhanced)", func() { - DescribeTable("returns enhanced lyrics, with cueLine only for word-level sources", - func(title string, wantCueLine bool) { - resp := doReq("getLyricsBySongId", "id", songID(title), "enhanced", "true") - Expect(resp.Status).To(Equal("ok")) - got := firstLyric(resp.LyricsList) - - Expect(got.Kind).To(Equal("main")) - if wantCueLine { - Expect(got.CueLine).ToNot(BeEmpty()) - // The first line has one cue per word: "Should auld acquaintance be forgot,". - Expect(got.CueLine[0].Cue).To(HaveLen(5)) - Expect(got.CueLine[0].Cue[0].Value).To(Equal("Should ")) - } else { - Expect(got.CueLine).To(BeEmpty()) - Expect(got.Line).ToNot(BeEmpty()) - } - }, - Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true), - Entry("embedded TTML (word-level spans)", "Embedded TTML", true), - Entry("YAML sidecar (word-level)", "Sidecar YAML", true), - Entry("embedded plain text (no timing)", "Embedded Plain", false), - Entry("LRC sidecar (line-level)", "Sidecar LRC", false), - Entry("SRT sidecar (line-level)", "Sidecar SRT", false), - ) - }) - - // getLyrics is the original Subsonic (pre-OpenSubsonic) endpoint. It looks up - // by artist/title and returns the main lyric flattened to plain text — every - // line's Value joined by newlines, with all timing/markup dropped. Synced and - // word-level formats (ELRC/TTML/SRT/YAML) all degrade to plain text here. - Describe("getLyrics (legacy artist/title)", func() { - DescribeTable("returns the main lyric as plain text across formats and sources", - func(title string) { - resp := doReq("getLyrics", "artist", "Lyric Tester", "title", title) - Expect(resp.Status).To(Equal("ok")) - Expect(resp.Lyrics).ToNot(BeNil()) - Expect(resp.Lyrics.Artist).To(Equal("Lyric Tester")) - Expect(resp.Lyrics.Title).To(Equal(title)) - Expect(resp.Lyrics.Value).To(ContainSubstring(firstFixtureLine)) - - // No timing markup leaks into the plain-text value, regardless of the - // source format: no LRC brackets/word markers, SRT arrows, or XML tags. - Expect(resp.Lyrics.Value).ToNot(ContainSubstring("[")) - Expect(resp.Lyrics.Value).ToNot(ContainSubstring("-->")) - 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/subsonic/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go deleted file mode 100644 index cf3e55d74..000000000 --- a/server/subsonic/e2e/subsonic_sharing_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package e2e - -import ( - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Sharing Endpoints", Ordered, func() { - var shareID string - var albumID string - var songID string - - BeforeAll(func() { - conf.Server.EnableSharing = true - setupTestDB() - - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.name": "Abbey Road"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - albumID = albums[0].ID - - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Come Together"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - songID = songs[0].ID - }) - - It("getShares returns empty initially", func() { - resp := doReq("getShares") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(BeEmpty()) - }) - - It("createShare creates a share for an album", func() { - resp := doReq("createShare", "id", albumID, "description", "Check out this album") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(HaveLen(1)) - - share := resp.Shares.Share[0] - Expect(share.ID).ToNot(BeEmpty()) - Expect(share.Description).To(Equal("Check out this album")) - Expect(share.Username).To(Equal(adminUser.UserName)) - shareID = share.ID - }) - - It("getShares returns the created share", func() { - resp := doReq("getShares") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(HaveLen(1)) - - share := resp.Shares.Share[0] - Expect(share.ID).To(Equal(shareID)) - Expect(share.Description).To(Equal("Check out this album")) - Expect(share.Username).To(Equal(adminUser.UserName)) - Expect(share.Entry).ToNot(BeEmpty()) - }) - - It("updateShare modifies the description", func() { - resp := doReq("updateShare", "id", shareID, "description", "Updated description") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - // Verify update - resp = doReq("getShares") - Expect(resp.Shares.Share).To(HaveLen(1)) - Expect(resp.Shares.Share[0].Description).To(Equal("Updated description")) - }) - - It("deleteShare removes it", func() { - resp := doReq("deleteShare", "id", shareID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("getShares returns empty after deletion", func() { - resp := doReq("getShares") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(BeEmpty()) - }) - - It("createShare works with a song ID", func() { - resp := doReq("createShare", "id", songID, "description", "Great song") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(HaveLen(1)) - Expect(resp.Shares.Share[0].Description).To(Equal("Great song")) - Expect(resp.Shares.Share[0].Entry).To(HaveLen(1)) - }) - - It("createShare returns error when id parameter is missing", func() { - resp := doReq("createShare") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) - }) - - It("updateShare returns error when id parameter is missing", func() { - resp := doReq("updateShare") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("deleteShare returns error when id parameter is missing", func() { - resp := doReq("deleteShare") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - 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().GetAll(ctx, 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)) - }) -}) - -var _ = Describe("Sharing Downloadable Default", func() { - var albumID string - - BeforeEach(func() { - conf.Server.EnableSharing = true - setupTestDB() - conf.Server.EnableDownloads = true - albumID = albumIDByName("Abbey Road") - }) - - createShare := func(params ...string) *model.Share { - GinkgoHelper() - resp := doReq("createShare", append([]string{"id", albumID}, params...)...) - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares.Share).To(HaveLen(1)) - share, err := ds.Share().Get(ctx, resp.Shares.Share[0].ID) - Expect(err).ToNot(HaveOccurred()) - return share - } - - DescribeTable("createShare resolves downloadable", - func(defaultDownloadable, enableDownloads bool, params []string, expected bool) { - conf.Server.DefaultDownloadableShare = defaultDownloadable - conf.Server.EnableDownloads = enableDownloads - - Expect(createShare(params...).Downloadable).To(Equal(expected)) - }, - Entry("applies the default when the param is absent", true, true, nil, true), - Entry("stays off when the default is off", false, true, nil, false), - Entry("ignores the default when downloads are disabled", true, false, nil, false), - Entry("honors an explicit false over the default", true, true, []string{"downloadable", "false"}, false), - Entry("honors an explicit true over the default", false, true, []string{"downloadable", "true"}, true), - ) - - It("updateShare keeps the current downloadable when the param is absent", func() { - conf.Server.DefaultDownloadableShare = true - share := createShare() - Expect(share.Downloadable).To(BeTrue()) - - resp := doReq("updateShare", "id", share.ID, "description", "Updated") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - updated, err := ds.Share().Get(ctx, share.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Description).To(Equal("Updated")) - Expect(updated.Downloadable).To(BeTrue()) - }) - - It("updateShare applies an explicit downloadable and keeps the description", func() { - conf.Server.DefaultDownloadableShare = true - share := createShare("description", "Keep me") - - resp := doReq("updateShare", "id", share.ID, "downloadable", "false") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - updated, err := ds.Share().Get(ctx, share.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Downloadable).To(BeFalse()) - Expect(updated.Description).To(Equal("Keep me")) - }) - - It("updateShare clears the description when it is sent empty", func() { - share := createShare("description", "Clear me") - - resp := doReq("updateShare", "id", share.ID, "description", "") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - updated, err := ds.Share().Get(ctx, share.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Description).To(BeEmpty()) - }) -}) diff --git a/server/filter/filters.go b/server/subsonic/filter/filters.go similarity index 50% rename from server/filter/filters.go rename to server/subsonic/filter/filters.go index 9014bb799..8ba4f0ff9 100644 --- a/server/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -47,26 +47,17 @@ func AlbumsByArtist() Options { } func AlbumsByArtistID(artistId string) Options { - roles := []model.Role{model.RoleAlbumArtist} + filters := []Sqlizer{ + persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}), + } if conf.Server.Subsonic.ArtistParticipations { - roles = append(roles, model.RoleArtist) + filters = append(filters, + persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}), + ) } return addDefaultFilters(Options{ Sort: "max_year", - Filters: persistence.ParticipantIDFilter("album", artistId, roles...), - }) -} - -// AlbumsByContributingArtistID matches albums where the artist performs on a track but is not the -// album artist — Jellyfin's "Featured On". The disjoint complement of AlbumsByArtistID, so an -// artist's own discography never leaks into it. -func AlbumsByContributingArtistID(artistId string) Options { - return addDefaultFilters(Options{ - Sort: "max_year", - Filters: And{ - persistence.ParticipantIDFilter("album", artistId, model.RoleArtist), - persistence.NotParticipantIDFilter("album", artistId, model.RoleAlbumArtist), - }, + Filters: Or(filters), }) } @@ -99,20 +90,13 @@ func SongsByAlbum(albumId string) Options { }) } -// SongsByArtistID matches media files where the artist participates as album or track artist, in -// album order. -func SongsByArtistID(artistId string) Options { - return addDefaultFilters(Options{ - Sort: "album", - Filters: persistence.ParticipantIDFilter("media_file", artistId, model.RoleArtist, model.RoleAlbumArtist), - }) -} - -func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { - options := Options{} +func SongsByRandom(genre string, fromYear, toYear int) Options { + options := Options{ + Sort: "random", + } ff := And{} if genre != "" { - ff = append(ff, persistence.SongGenres.ByName(genre)) + ff = append(ff, filterByGenre(genre)) } if fromYear != 0 { ff = append(ff, GtOrEq{"year": fromYear}) @@ -124,6 +108,21 @@ func SongsByGenreAndYearRange(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 @@ -156,62 +155,18 @@ func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options { return opts } -// ArtistsByRole restricts an artist query to artists appearing in the given role (album artist, -// performer, composer, ...) via library_artist.stats. An unknown role is ignored (no filter). -func ArtistsByRole(opts Options, role model.Role) Options { - if _, ok := model.AllRoles[role.String()]; !ok { - return opts - } - roleFilter := Expr("JSON_EXTRACT(library_artist.stats, '$." + role.String() + ".m') IS NOT NULL") - if opts.Filters == nil { - opts.Filters = roleFilter - } else { - opts.Filters = And{opts.Filters, roleFilter} - } - return opts +func ByGenre(genre string) Options { + return addDefaultFilters(Options{ + Sort: "name", + Filters: filterByGenre(genre), + }) } -// SongsByGenreID / AlbumsByGenreID (by tag id) and AlbumsByGenre / SongsByGenre (by name, wrapped -// as Options for Subsonic) delegate to the persistence genre filters, which own the join schema. -func SongsByGenreID(genreIds []string) Sqlizer { return persistence.SongGenres.ByID(genreIds) } -func AlbumsByGenreID(genreIds []string) Sqlizer { return persistence.AlbumGenres.ByID(genreIds) } - -func AlbumsByGenre(genre string) Options { - return addDefaultFilters(Options{Sort: "name", Filters: persistence.AlbumGenres.ByName(genre)}) -} - -func SongsByGenre(genre string) Options { - return addDefaultFilters(Options{Sort: "name", Filters: persistence.SongGenres.ByName(genre)}) -} - -// ByAlbumID matches media files belonging to any of the given albums. -func ByAlbumID(albumIds []string) Sqlizer { - return Eq{"album_id": albumIds} -} - -// AlbumsByYears matches albums whose production year (max_year) is in years. -func AlbumsByYears(years []int) Sqlizer { - return Eq{"max_year": years} -} - -// SongsByYears matches media files whose year is in years. -func SongsByYears(years []int) Sqlizer { - return Eq{"year": years} -} - -func ArtistsByGenreID(genreIds []string) Sqlizer { - return persistence.AlbumArtistsByGenreID(genreIds) -} - -// tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each -// entry against cond (its name via Like, or its tag id via Eq/IN). -func tagIDFilter(tagName string, cond Sqlizer) Sqlizer { - return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond}) -} - -// ByStudioID matches items (albums or songs) whose record-label tag id is in ids. -func ByStudioID(ids []string) Sqlizer { - return tagIDFilter("recordlabel", Eq{"value": ids}) +func filterByGenre(genre string) Sqlizer { + return persistence.Exists(`json_tree(tags, "$.genre")`, And{ + Like{"value": genre}, + NotEq{"atom": nil}, + }) } func ByRating() Options { diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 55b1b213e..8a9eb7110 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" @@ -95,24 +95,13 @@ func getArtistAlbumCount(a *model.Artist) int32 { } } -// coverArtOrEmpty stamps the (hash-suffixed) coverArt id, or omits it (empty -// string + the field's omitempty tag) when artwork is known absent. -func coverArtOrEmpty(id model.ArtworkID, absent bool) string { - if absent { - return "" - } - return id.String() -} - func toArtist(r *http.Request, a model.Artist) responses.Artist { artist := responses.Artist{ - Id: a.ID, - Name: a.Name, - UserRating: int32(a.Rating), - CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), - } - if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) + Id: a.ID, + Name: a.Name, + UserRating: int32(a.Rating), + CoverArt: a.CoverArtID().String(), + ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -125,14 +114,12 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist { func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { artist := responses.ArtistID3{ - Id: a.ID, - Name: a.Name, - AlbumCount: getArtistAlbumCount(&a), - CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), - UserRating: int32(a.Rating), - } - if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) + Id: a.ID, + Name: a.Name, + AlbumCount: getArtistAlbumCount(&a), + CoverArt: a.CoverArtID().String(), + ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), + UserRating: int32(a.Rating), } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -221,7 +208,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Size = mf.Size child.Suffix = mf.Suffix child.BitRate = int32(mf.BitRate) - child.CoverArt = coverArtOrEmpty(mf.CoverArtID(), mf.ImageAbsent) + child.CoverArt = mf.CoverArtID().String() child.ContentType = mf.ContentType() if ok && player.ReportRealPath { @@ -230,7 +217,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Path = fakePath(mf) } child.DiscNumber = int32(mf.DiscNumber) - child.Created = new(mediaFileCreatedAt(mf)) + child.Created = P(mf.BirthTime) child.AlbumId = mf.AlbumID child.ArtistId = mf.ArtistID child.Type = "music" @@ -264,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(gg.V(mf.BPM)) + child.BPM = int32(mf.BPM) child.MediaType = responses.MediaTypeSong child.MusicBrainzId = mf.MbzRecordingID child.Isrc = mf.Tags.Values(model.TagISRC) @@ -276,7 +263,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op } child.ChannelCount = int32(mf.Channels) child.SamplingRate = int32(mf.SampleRate) - child.BitDepth = int32(gg.V(mf.BitDepth)) + child.BitDepth = int32(mf.BitDepth) child.Genres = toItemGenres(mf.Genres) child.Moods = mf.Tags.Values(model.TagMood) child.Groupings = mf.Tags.Values(model.TagGrouping) @@ -303,12 +290,6 @@ 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 } @@ -339,36 +320,18 @@ func sanitizeSlashes(target string) string { return strings.ReplaceAll(target, "/", "_") } -// 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. +// 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. func albumCreatedAt(al model.Album) time.Time { - candidates := []time.Time{al.CreatedAt, al.UpdatedAt, al.ImportedAt} - if conf.Server.RecentlyAddedByModTime { - candidates = []time.Time{al.UpdatedAt, al.CreatedAt, al.ImportedAt} + if !al.CreatedAt.IsZero() { + return al.CreatedAt } - for _, t := range candidates { - if !t.IsZero() { - return t - } + if !al.UpdatedAt.IsZero() { + return al.UpdatedAt } - 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{} + return al.ImportedAt } func childFromAlbum(ctx context.Context, al model.Album) responses.Child { @@ -382,8 +345,8 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Artist = al.AlbumArtist child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre - child.CoverArt = coverArtOrEmpty(al.CoverArtID(), al.ImageAbsent) - child.Created = new(albumCreatedAt(al)) + child.CoverArt = al.CoverArtID().String() + child.Created = P(albumCreatedAt(al)) child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID child.Duration = int32(al.Duration) @@ -473,13 +436,13 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.Name = album.FullName() dir.Artist = album.AlbumArtist dir.ArtistId = album.AlbumArtistID - dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) + dir.CoverArt = album.CoverArtID().String() dir.SongCount = int32(album.SongCount) dir.Duration = int32(album.Duration) dir.PlayCount = album.PlayCount dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) dir.Genre = album.Genre - dir.Created = albumCreatedAt(album) + dir.Created = P(albumCreatedAt(album)) if album.Starred { dir.Starred = album.StarredAt } @@ -515,7 +478,9 @@ func buildOSAlbumID3(ctx context.Context, album model.Album) *responses.OpenSubs dir.IsCompilation = album.Compilation dir.DiscTitles = buildDiscSubtitles(album) dir.ExplicitStatus = mapExplicitStatus(album.ExplicitStatus) - dir.Version = album.Tags.First(model.TagAlbumVersion) + if len(album.Tags.Values(model.TagAlbumVersion)) > 0 { + dir.Version = album.Tags.Values(model.TagAlbumVersion)[0] + } return &dir } @@ -530,6 +495,48 @@ 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 69fda4681..abf6116f3 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -2,7 +2,6 @@ package subsonic import ( "context" - "encoding/json" "net/http/httptest" "time" @@ -22,7 +21,6 @@ var _ = Describe("helpers", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test public secret"), nil) }) Describe("fakePath", func() { @@ -321,82 +319,6 @@ var _ = Describe("helpers", func() { }) }) - Describe("artwork coverArt emission", func() { - const hash = "0123456789abcdef" - ctx := context.Background() - - DescribeTable("coverArtOrEmpty", - func(id model.ArtworkID, absent bool, expected string) { - Expect(coverArtOrEmpty(id, absent)).To(Equal(expected)) - }, - Entry("emits the bare id when unresolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1"}, false, "al-1"), - Entry("emits the hash-suffixed id when resolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, false, "al-1_"+hash), - Entry("omits (empty) when known absent", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, true, ""), - ) - - Describe("childFromAlbum", func() { - It("suffixes coverArt with the content hash when resolved", func() { - al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageHash: hash}} - Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1_" + hash)) - }) - It("omits coverArt when known absent", func() { - al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} - Expect(childFromAlbum(ctx, al).CoverArt).To(BeEmpty()) - }) - It("emits the bare id when unresolved", func() { - al := model.Album{ID: "al-1"} - Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1")) - }) - }) - - Describe("childFromMediaFile", func() { - // An al- id must carry the album's hash (AlbumImage), never the track's own. - It("suffixes coverArt with the album's content hash when resolved", func() { - mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", AlbumImage: model.ItemImage{ImageHash: hash}} - Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1_" + hash)) - }) - - It("does not stamp a track's own art hash onto the album id", func() { - mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", - ItemImage: model.ItemImage{ImageHash: "0000ownarthash00"}, - AlbumImage: model.ItemImage{ImageHash: hash}} - Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1_" + hash)) - }) - It("omits coverArt when known absent", func() { - mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} - Expect(childFromMediaFile(ctx, mf).CoverArt).To(BeEmpty()) - }) - It("emits the bare id when unresolved", func() { - mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1"} - Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1")) - }) - }) - - Describe("toArtist / toArtistID3", func() { - r := httptest.NewRequest("GET", "/test", nil) - It("emits both coverArt and imageUrl when resolved", func() { - a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageHash: hash}} - artist := toArtist(r, a) - Expect(artist.CoverArt).To(Equal("ar-ar-1_" + hash)) - Expect(artist.ArtistImageUrl).ToNot(BeEmpty()) - }) - It("omits coverArt and imageUrl when known absent", func() { - a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}} - artist := toArtist(r, a) - Expect(artist.CoverArt).To(BeEmpty()) - Expect(artist.ArtistImageUrl).To(BeEmpty()) - - id3 := toArtistID3(r, a) - Expect(id3.CoverArt).To(BeEmpty()) - Expect(id3.ArtistImageUrl).To(BeEmpty()) - }) - It("emits an imageUrl when unresolved", func() { - a := model.Artist{ID: "ar-1"} - Expect(toArtist(r, a).ArtistImageUrl).ToNot(BeEmpty()) - }) - }) - }) - Describe("osChildFromMediaFile", func() { var mf model.MediaFile var ctx context.Context @@ -436,52 +358,6 @@ 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() { @@ -696,122 +572,34 @@ var _ = Describe("helpers", func() { }) Describe("buildAlbumID3 Created field", func() { - 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("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 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 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("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)) + }) - 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)) - }) + 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()) }) }) diff --git a/server/subsonic/jukebox.go b/server/subsonic/jukebox.go index d8bf53360..c4bc643ab 100644 --- a/server/subsonic/jukebox.go +++ b/server/subsonic/jukebox.go @@ -68,7 +68,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) case ActionStatus: return createResponse(pb.Status(ctx)) case ActionSet: - ids := p.Strings("id") + ids, _ := p.Strings("id") return createResponse(pb.Set(ctx, ids)) case ActionStart: return createResponse(pb.Start(ctx)) @@ -82,7 +82,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) offset := p.IntOr("offset", 0) return createResponse(pb.Skip(ctx, index, offset)) case ActionAdd: - ids := p.Strings("id") + ids, _ := p.Strings("id") return createResponse(pb.Add(ctx, ids)) case ActionClear: return createResponse(pb.Clear(ctx)) diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index 89e5cbc9b..bac27f821 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -40,20 +40,23 @@ 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) // Parse optional target parameters for selective scanning var targets []model.ScanTarget - if targetParams := p.Strings("target"); len(targetParams) > 0 { - var err error + if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 { targets, err = model.ParseTargets(targetParams) if err != nil { return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err)) } // Validate all libraries in targets exist and user has access to them - userLibraries, err := api.ds.User().GetUserLibraries(ctx, loggedUser.ID) + userLibraries, err := api.ds.User(ctx).GetUserLibraries(loggedUser.ID) if err != nil { return nil, newError(responses.ErrorGeneric, "Internal error") } @@ -67,7 +70,7 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { // Special case: if single library with empty path and it's the only library in DB, call ScanAll if len(targets) == 1 && targets[0].FolderPath == "" { - allLibs, err := api.ds.Library().GetAll(ctx) + allLibs, err := api.ds.Library(ctx).GetAll() if err != nil { return nil, newError(responses.ErrorGeneric, "Internal error") } diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go index e2b8827f4..c62c156bc 100644 --- a/server/subsonic/library_scanning_test.go +++ b/server/subsonic/library_scanning_test.go @@ -23,6 +23,29 @@ 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{ @@ -80,7 +103,7 @@ var _ = Describe("LibraryScanning", func() { It("triggers a selective scan with single target parameter", func() { // Setup mocks mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} api.ds = mockDS @@ -116,7 +139,7 @@ var _ = Describe("LibraryScanning", func() { It("triggers a selective scan with multiple target parameters", func() { // Setup mocks mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} api.ds = mockDS @@ -154,7 +177,7 @@ var _ = Describe("LibraryScanning", func() { It("triggers a selective full scan with target and fullScan parameters", func() { // Setup mocks mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1}) + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1}) mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} api.ds = mockDS @@ -235,7 +258,7 @@ var _ = Describe("LibraryScanning", func() { It("returns error when library does not exist", func() { // Setup mocks - user has access to library 1 and 2 only mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} api.ds = mockDS @@ -264,7 +287,7 @@ var _ = Describe("LibraryScanning", func() { It("calls ScanAll when single library with empty path and only one library exists", func() { // Setup mocks - single library in DB mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1}) + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1}) mockLibraryRepo := &tests.MockLibraryRepo{} mockLibraryRepo.SetData(model.Libraries{ {ID: 1, Name: "Music Library", Path: "/music"}, @@ -302,7 +325,7 @@ var _ = Describe("LibraryScanning", func() { It("calls ScanFolders when single library with empty path but multiple libraries exist", func() { // Setup mocks - multiple libraries in DB mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) + _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2}) mockLibraryRepo := &tests.MockLibraryRepo{} mockLibraryRepo.SetData(model.Libraries{ {ID: 1, Name: "Music Library", Path: "/music"}, diff --git a/server/subsonic/lyrics.go b/server/subsonic/lyrics.go deleted file mode 100644 index bfe1f1899..000000000 --- a/server/subsonic/lyrics.go +++ /dev/null @@ -1,265 +0,0 @@ -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 deleted file mode 100644 index 8713b7a3b..000000000 --- a/server/subsonic/lyrics_test.go +++ /dev/null @@ -1,842 +0,0 @@ -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 d6d160570..e8b0278c1 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -2,7 +2,6 @@ package subsonic import ( "context" - "errors" "fmt" "math" "net/http" @@ -48,19 +47,16 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { } switch entity.(type) { case *model.Artist: - repo = api.ds.Artist() + repo = api.ds.Artist(ctx) resource = "artist" case *model.Album: - repo = api.ds.Album() + repo = api.ds.Album(ctx) resource = "album" - case *model.Playlist: - repo = api.ds.Playlist() - resource = "playlist" default: - repo = api.ds.MediaFile() + repo = api.ds.MediaFile(ctx) resource = "song" } - err = repo.SetRating(ctx, rating, id) + err = repo.SetRating(rating, id) if err != nil { return err } @@ -71,9 +67,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids := p.Strings("id") - albumIds := p.Strings("albumId") - artistIds := p.Strings("artistId") + ids, _ := p.Strings("id") + albumIds, _ := p.Strings("albumId") + artistIds, _ := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -90,9 +86,9 @@ func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids := p.Strings("id") - albumIds := p.Strings("albumId") - artistIds := p.Strings("artistId") + ids, _ := p.Strings("id") + albumIds, _ := p.Strings("albumId") + artistIds, _ := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -109,49 +105,47 @@ 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 { - log.Warn(ctx, "Cannot star/unstar an empty list of ids") 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{} err := api.ds.WithTxImmediate(func(tx model.DataStore) error { - event := &events.RefreshResource{} - changed := false for _, id := range ids { - var repo model.AnnotatedRepository - var resource string - entity, err := model.GetEntityByID(ctx, tx, id) + exist, err := tx.Album(ctx).Exists(id) if err != nil { - if !errors.Is(err, model.ErrNotFound) { - return err - } - log.Warn(ctx, "Cannot star/unstar unknown id, skipping", "id", id) - continue - } - switch entity.(type) { - case *model.Artist: - repo = tx.Artist() - resource = "artist" - case *model.Album: - repo = tx.Album() - resource = "album" - case *model.Playlist: - repo = tx.Playlist() - resource = "playlist" - default: - repo = tx.MediaFile() - resource = "song" - } - if err := repo.SetStar(ctx, star, id); err != nil { return err } - 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) + if exist { + err = tx.Album(ctx).SetStar(star, id) + if err != nil { + return err + } + event = event.With("album", id) + continue + } + exist, err = tx.Artist(ctx).Exists(id) + if 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) } + api.broker.SendMessage(ctx, event) return nil }) if err != nil { @@ -163,9 +157,9 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids := p.Strings("id") - if len(ids) == 0 { - return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") + ids, err := p.Strings("id") + if err != nil { + return nil, err } times, _ := p.Times("time") if len(times) > 0 && len(times) != len(ids) { @@ -181,9 +175,6 @@ func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { log.Error(ctx, "Error registering scrobbles", "ids", ids, "times", times, err) } } else { - if len(ids) > 1 { - log.Warn(ctx, "Multiple ids sent to a nowPlaying notification, only the first one will be used", "ids", ids) - } err := api.scrobblerNowPlaying(ctx, ids[0], position) if err != nil { log.Error(ctx, "Error setting NowPlaying", "id", ids[0], err) @@ -210,7 +201,7 @@ func (api *Router) scrobblerSubmit(ctx context.Context, ids []string, times []ti } func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, position int) error { - mf, err := api.ds.MediaFile().Get(ctx, trackId) + mf, err := api.ds.MediaFile(ctx).Get(trackId) if err != nil { return err } diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 9948929e3..487335d1a 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -77,7 +77,7 @@ var _ = Describe("MediaAnnotationController", func() { Context("submission=false", func() { var req *http.Request BeforeEach(func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "12"}) + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "12"}) ctx = request.WithPlayer(ctx, model.Player{ID: "player-1"}) req = newGetRequest("id=12", "submission=false") req = req.WithContext(ctx) @@ -185,64 +185,6 @@ 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 f5e2ceb25..9ab3a20b0 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -13,7 +13,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/server/imghttp" + "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" @@ -29,7 +29,7 @@ func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses return nil, err } ctx := r.Context() - u, err := api.ds.User().FindByUsername(ctx, username) + u, err := api.ds.User(ctx).FindByUsername(username) if err != nil { return nil, err } @@ -67,7 +67,7 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons size := p.IntOr("size", 0) square := p.BoolOr("square", false) - img, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) + imgReader, lastUpdate, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) switch { case errors.Is(err, context.Canceled): return nil, nil @@ -79,13 +79,11 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons return nil, err } - defer img.Close() + defer imgReader.Close() + w.Header().Set("cache-control", "public, max-age=315360000") + w.Header().Set("last-modified", lastUpdate.Format(http.TimeFormat)) - artID, _ := model.ParseArtworkID(id) - if imghttp.WriteImageHeaders(w, r, img, artID.Hash) { - return nil, nil - } - cnt, err := io.Copy(w, img) + cnt, err := io.Copy(w, imgReader) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } @@ -100,13 +98,22 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { response := newResponse() lyricsResponse := responses.Lyrics{} response.Lyrics = &lyricsResponse - structuredLyrics, err := api.lyrics.GetLyricsByArtistTitle(r.Context(), artist, title) + mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title)) + if err != nil { return nil, err } - mainLyric, ok := structuredLyrics.Main() - if !ok { + 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 { return response, nil } @@ -114,9 +121,10 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { lyricsResponse.Title = title var lyricsText strings.Builder - for _, line := range mainLyric.Line { + for _, line := range structuredLyrics[0].Line { lyricsText.WriteString(line.Value + "\n") } + lyricsResponse.Value = lyricsText.String() return response, nil @@ -128,7 +136,7 @@ func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, erro return nil, err } - mediaFile, err := api.ds.MediaFile().Get(r.Context(), id) + mediaFile, err := api.ds.MediaFile(r.Context()).Get(id) if err != nil { return nil, err } @@ -138,10 +146,8 @@ 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, enhanced) + response.LyricsList = buildLyricsList(mediaFile, structuredLyrics) return response, nil } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index ad193518d..27d1edb84 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -8,7 +8,6 @@ import ( "errors" "io" "net/http/httptest" - "path/filepath" "slices" "time" @@ -17,7 +16,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -31,17 +30,11 @@ var _ = Describe("MediaRetrievalController", func() { var w *httptest.ResponseRecorder BeforeEach(func() { - albumRepo := &tests.MockAlbumRepo{} - albumRepo.SetData(model.Albums{{ID: "34"}}) // the id the specs request, made accessible - radioRepo := tests.CreateMockedRadioRepo() - Expect(radioRepo.Put(GinkgoT().Context(), &model.Radio{ID: "rd1", Name: "Radio"})).To(Succeed()) ds = &tests.MockDataStore{ MockedMediaFile: mockRepo, - MockedAlbum: albumRepo, - MockedRadio: radioRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" @@ -67,22 +60,6 @@ var _ = Describe("MediaRetrievalController", func() { Expect(w.Body.String()).To(Equal(artwork.data)) }) - // The service applies the library filter from the caller's context, so elevating here - // would bypass it. - It("passes the caller's context to the service rather than elevating", func() { - r := newGetRequest("id=al-34") - usr := model.User{ID: "u1", UserName: "u1"} - r = r.WithContext(request.WithUser(r.Context(), usr)) - - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - got, ok := request.UserFrom(artwork.recvCtx) - Expect(ok).To(BeTrue(), "the service must see who is asking") - Expect(got.ID).To(Equal("u1")) - Expect(got.IsAdmin).To(BeFalse(), "the handler must not elevate") - }) - It("should fail when the file is not found", func() { artwork.err = model.ErrNotFound r := newGetRequest("id=34", "size=128", "square=true") @@ -101,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(GinkgoT().Context()) + ctx, cancel := context.WithCancel(context.Background()) r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) cancel() @@ -116,7 +93,7 @@ var _ = Describe("MediaRetrievalController", func() { }) It("should not return data if cancelled during the call", func() { - ctx, cancel := context.WithCancel(GinkgoT().Context()) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) @@ -131,71 +108,39 @@ var _ = Describe("MediaRetrievalController", func() { Expect(w.Body.String()).To(BeEmpty()) }) }) - - Describe("caching headers", func() { - const hash = "0123456789abcdef" - - It("sets an ETag and no-cache for a bare id", func() { - artwork.hash = hash - r := newGetRequest("id=al-34") - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) - Expect(w.Body.String()).To(Equal(artwork.data)) - }) - - It("marks the response immutable when the id asserts the current hash", func() { - artwork.hash = hash - r := newGetRequest("id=al-34_" + hash) - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) - }) - - It("returns 304 with no body when If-None-Match matches", func() { - artwork.hash = hash - r := newGetRequest("id=al-34") - r.Header.Set("If-None-Match", `"`+hash+`"`) - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(w.Code).To(Equal(304)) - Expect(w.Body.Len()).To(BeZero()) - }) - - It("never caches a placeholder", func() { - artwork.placeholder = true - r := newGetRequest("id=al-missing") - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(w.Code).To(Equal(200)) - Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) - Expect(w.Header().Get("ETag")).To(BeEmpty()) - Expect(w.Body.String()).To(Equal(artwork.data)) - }) - }) }) Describe("GetLyrics", func() { It("should return data for given artist & title", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") - 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() + 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") 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: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), + 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 }, }) response, err := router.GetLyrics(r) @@ -204,26 +149,6 @@ 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{}) @@ -235,15 +160,18 @@ 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{ { - LibraryPath: fixturesDir, - Path: "test.mp3", - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", + 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", }, }) response, err := router.GetLyrics(r) @@ -253,48 +181,175 @@ 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 { artwork.Artwork data string - hash string - lastUpdated time.Time - placeholder bool err error ctxCancelFunc func() recvId string recvSize int recvSquare bool - recvCtx context.Context } -func (c *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { - c.recvCtx = ctx +func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { if c.err != nil { - return nil, c.err + return nil, time.Time{}, c.err } c.recvId = id c.recvSize = size c.recvSquare = square if c.ctxCancelFunc != nil { c.ctxCancelFunc() - return nil, context.Canceled + return nil, time.Time{}, context.Canceled } - return &artwork.Image{ - ReadCloser: io.NopCloser(bytes.NewReader([]byte(c.data))), - Hash: c.hash, - LastUpdated: c.lastUpdated, - Placeholder: c.placeholder, - }, nil + return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil } type mockedMediaFile struct { tests.MockMediaFileRepo } -func (m *mockedMediaFile) GetAll(ctx context.Context, opts ...model.QueryOptions) (model.MediaFiles, error) { - data, err := m.MockMediaFileRepo.GetAll(ctx, opts...) +func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, error) { + data, err := m.MockMediaFileRepo.GetAll(opts...) if err != nil { return nil, err } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index fdb4af28d..5832bb1de 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -65,15 +65,14 @@ func checkRequiredParameters(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var requiredParameters []string - p := req.Params(r) username, _ := fromInternalOrProxyAuth(r) - apiKey, _ := p.String("apiKey") - if username != "" || apiKey != "" { + if username != "" { requiredParameters = []string{"v", "c"} } else { requiredParameters = []string{"u", "v", "c"} } + p := req.Params(r) for _, param := range requiredParameters { if _, err := p.String(param); err != nil { log.Warn(r, err) @@ -99,22 +98,17 @@ func checkRequiredParameters(next http.Handler) http.Handler { } func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { - limiter := newAuthLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var usr *model.User - var keyPlayer *model.Player var err error - p := req.Params(r) - apiKey, _ := p.String("apiKey") username, isInternalAuth := fromInternalOrProxyAuth(r) - switch { - case username != "": + if username != "" { authType := If(isInternalAuth, "internal", "reverse-proxy") - usr, err = ds.User().FindByUsername(ctx, username) + usr, err = ds.User(ctx).FindByUsername(username) if errors.Is(err, context.Canceled) { log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) return @@ -124,51 +118,29 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } else if err != nil { log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) } - case apiKey != "": - usr, keyPlayer, err = authenticateAPIKey(ctx, ds, limiter, r, apiKey) - if err != nil { - if ctx.Err() == nil { - sendError(w, r, err) - } - return - } - ctx = request.WithUsername(ctx, usr.UserName) - default: + } else { + p := req.Params(r) username, _ := p.String("u") pass, _ := p.String("p") token, _ := p.String("t") salt, _ := p.String("s") jwt, _ := p.String("jwt") - // Blocked attempts get the same response as a wrong password, so they reveal nothing - limitKey := server.ClientIP(r) + "\x00" + strings.ToLower(username) - slot, allowed := limiter.acquire(ctx, limitKey) - if !allowed { - if ctx.Err() != nil { - return - } - log.Warn(ctx, "API: Too many failed login attempts", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr) - sendError(w, r, newError(responses.ErrorAuthenticationFail)) - return - } - - usr, err = ds.User().FindByUsernameWithPassword(ctx, username) - if err == nil { - err = validateCredentials(usr, pass, token, salt, jwt) - if errors.Is(err, model.ErrInvalidAuth) && pass != "" && jwt == "" { - keyPlayer, err = playerFromPasswordKey(ctx, ds, usr, pass) - } - } - invalidLogin := errors.Is(err, model.ErrNotFound) || errors.Is(err, model.ErrInvalidAuth) - slot.release(invalidLogin) - switch { - case errors.Is(err, context.Canceled): + usr, err = ds.User(ctx).FindByUsernameWithPassword(username) + if errors.Is(err, context.Canceled) { log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) return - case invalidLogin: + } + switch { + case errors.Is(err, model.ErrNotFound): log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) case err != nil: log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + default: + err = validateCredentials(usr, pass, token, salt, jwt) + if err != nil { + log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + } } } @@ -178,105 +150,25 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } ctx = request.WithUser(ctx, *usr) - if keyPlayer != nil { - ctx = request.WithPlayer(ctx, *keyPlayer) - } next.ServeHTTP(w, r.WithContext(ctx)) }) } } -var apiKeyConflicts = []string{"u", "p", "t", "s", "jwt"} - -func authenticateAPIKey(ctx context.Context, ds model.DataStore, limiter *authLimiter, r *http.Request, key string) (*model.User, *model.Player, error) { - query := r.URL.Query() - for _, param := range apiKeyConflicts { - if query.Has(param) { - log.Warn(ctx, "API: apiKey sent with other credentials", "auth", "apikey", "param", param, "remoteAddr", r.RemoteAddr) - return nil, nil, newError(responses.ErrorMultipleAuthMechanismsProvided) - } - } - - // Per key, so a stale key on one device cannot lock out valid keys sharing the IP - slot, allowed := limiter.acquire(ctx, "apikey\x00"+server.ClientIP(r)+"\x00"+key) - if !allowed { - if err := ctx.Err(); err != nil { - return nil, nil, err - } - log.Warn(ctx, "API: Too many failed API key attempts", "auth", "apikey", "remoteAddr", r.RemoteAddr) - return nil, nil, newError(responses.ErrorInvalidAPIKey) - } - - player, err := ds.Player().FindByAPIKey(ctx, key) - var usr *model.User - if err == nil { - usr, err = ds.User().Get(ctx, player.UserId) - } - slot.release(errors.Is(err, model.ErrNotFound)) - switch { - case errors.Is(err, context.Canceled): - return nil, nil, err - case errors.Is(err, model.ErrNotFound): - log.Warn(ctx, "API: Invalid API key", "auth", "apikey", "remoteAddr", r.RemoteAddr) - return nil, nil, newError(responses.ErrorInvalidAPIKey) - case err != nil: - log.Error(ctx, "API: Error authenticating API key", "auth", "apikey", "remoteAddr", r.RemoteAddr, err) - return nil, nil, newError(responses.ErrorAuthenticationFail) - } - return usr, player, nil -} - -// playerFromPasswordKey lets clients that only have a password field log in with an API key. -// It returns ErrInvalidAuth when pass is not a key of usr, so only real failures skip the limiter count. -func playerFromPasswordKey(ctx context.Context, ds model.DataStore, usr *model.User, pass string) (*model.Player, error) { - key := decodePassword(pass) - if !strings.HasPrefix(key, consts.APIKeyPrefix) { - return nil, model.ErrInvalidAuth - } - plr, err := ds.Player().FindByAPIKey(ctx, key) - if errors.Is(err, model.ErrNotFound) || (err == nil && plr.UserId != usr.ID) { - return nil, model.ErrInvalidAuth - } - return plr, err -} - -func decodePassword(pass string) string { - if strings.HasPrefix(pass, "enc:") { - if dec, err := hex.DecodeString(pass[4:]); err == nil { - return string(dec) - } - } - return pass -} - -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 switch { case jwt != "": claims, err := auth.Validate(jwt) - valid = err == nil && - claims.Subject == user.UserName && - auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil + valid = err == nil && claims.Subject == user.UserName case pass != "": - valid = decodePassword(pass) == user.Password + if strings.HasPrefix(pass, "enc:") { + if dec, err := hex.DecodeString(pass[4:]); err == nil { + pass = string(dec) + } + } + valid = pass == user.Password case token != "": t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt))) valid = t == token @@ -294,20 +186,12 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { ctx := r.Context() userName, _ := request.UsernameFrom(ctx) client, _ := request.ClientFrom(ctx) + playerId := playerIDFromCookie(r, userName) ip, _, _ := net.SplitHostPort(r.RemoteAddr) userAgent := canonicalUserAgent(r) - - var player *model.Player - var trc *model.Transcoding - var err error - keyPlayer, boundByKey := request.PlayerFrom(ctx) - if boundByKey { - player, trc, err = players.Touch(ctx, keyPlayer, client, userAgent, ip) - } else { - player, trc, err = players.Register(ctx, playerIDFromCookie(r, userName), client, userAgent, ip) - } + player, trc, err := players.Register(ctx, playerId, client, userAgent, ip) if err != nil { - log.Error(ctx, "Could not resolve player", "username", userName, "client", client, err) + log.Error(ctx, "Could not register player", "username", userName, "client", client, err) } else { ctx = request.WithPlayer(ctx, *player) if trc != nil { @@ -315,11 +199,6 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { } r = r.WithContext(ctx) - // A key already identifies the player, so the cookie would only add a second, weaker signal - if boundByKey { - next.ServeHTTP(w, r) - return - } cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP Name: playerIDCookieName(userName), Value: player.ID, diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index b3ad972b7..aba14a0aa 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -3,14 +3,11 @@ package subsonic import ( "context" "crypto/md5" - "encoding/hex" "errors" "fmt" "net/http" "net/http/httptest" "strings" - "sync" - "sync/atomic" "time" "github.com/navidrome/navidrome/conf" @@ -43,13 +40,11 @@ func newPostRequest(queryParam string, formFields ...string) *http.Request { } var _ = Describe("Middlewares", func() { - var ctx context.Context var next *mockHandler var w *httptest.ResponseRecorder var ds model.DataStore BeforeEach(func() { - ctx = GinkgoT().Context() next = &mockHandler{} w = httptest.NewRecorder() ds = &tests.MockDataStore{} @@ -120,14 +115,6 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeTrue()) }) - It("does not require u when apiKey is present", func() { - r := newGetRequest("apiKey=nds_abc", "v=1.15", "c=test") - cp := checkRequiredParameters(next) - cp.ServeHTTP(w, r) - - Expect(next.called).To(BeTrue()) - }) - It("fails when user is missing", func() { r := newGetRequest("v=1.15", "c=test") cp := checkRequiredParameters(next) @@ -158,8 +145,8 @@ var _ = Describe("Middlewares", func() { Describe("Authenticate", func() { BeforeEach(func() { - ur := ds.User() - _ = ur.Put(ctx, &model.User{ + ur := ds.User(context.TODO()) + _ = ur.Put(&model.User{ UserName: "admin", NewPassword: "wordpass", }) @@ -319,325 +306,6 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeFalse()) }) }) - - When("using API key authentication", func() { - var key string - serve := func(params ...string) { - authenticate(ds)(next).ServeHTTP(w, newGetRequest(params...)) - } - - BeforeEach(func() { - usr, err := ds.User().FindByUsername(ctx, "admin") - Expect(err).ToNot(HaveOccurred()) - Expect(ds.Player().Put(ctx, &model.Player{ID: "player-1", Name: "My Phone", UserId: usr.ID, Client: "Symfonium"})).To(Succeed()) - key = "nds_0123456789abcdefghijkl" - Expect(ds.Player().SetAPIKey(ctx, "player-1", key)).To(Succeed()) - }) - - It("authenticates the owner and binds the key's player", func() { - serve("apiKey=" + key) - - Expect(next.called).To(BeTrue()) - user, _ := request.UserFrom(next.req.Context()) - Expect(user.UserName).To(Equal("admin")) - username, _ := request.UsernameFrom(next.req.Context()) - Expect(username).To(Equal("admin")) - player, ok := request.PlayerFrom(next.req.Context()) - Expect(ok).To(BeTrue()) - Expect(player.ID).To(Equal("player-1")) - }) - - It("accepts the key in a POST form body", func() { - r := newPostRequest("", "apiKey="+key) - cp := postFormToQueryParams(authenticate(ds)(next)) - cp.ServeHTTP(w, r) - - Expect(next.called).To(BeTrue()) - player, _ := request.PlayerFrom(next.req.Context()) - Expect(player.ID).To(Equal("player-1")) - }) - - It("rejects an unknown key with error 44", func() { - serve("apiKey=nds_unknown") - - Expect(w.Body.String()).To(ContainSubstring(`code="44"`)) - Expect(next.called).To(BeFalse()) - }) - - DescribeTable("rejects apiKey mixed with other credentials with error 43", - func(extra string) { - serve("apiKey="+key, extra) - - Expect(w.Body.String()).To(ContainSubstring(`code="43"`)) - Expect(next.called).To(BeFalse()) - }, - Entry("u", "u=admin"), - Entry("p", "p=wordpass"), - Entry("t", "t=abc"), - Entry("s", "s=abc"), - Entry("jwt", "jwt=abc"), - Entry("empty u", "u="), - Entry("empty p", "p="), - ) - - Context("key sent as the password", func() { - It("authenticates and binds the key's player", func() { - serve("u=admin", "p="+key) - - Expect(next.called).To(BeTrue()) - player, ok := request.PlayerFrom(next.req.Context()) - Expect(ok).To(BeTrue()) - Expect(player.ID).To(Equal("player-1")) - }) - - It("accepts the hex-encoded form", func() { - serve("u=admin", "p=enc:"+hex.EncodeToString([]byte(key))) - - Expect(next.called).To(BeTrue()) - }) - - It("still accepts a real password that starts with the key prefix", func() { - Expect(ds.User().Put(ctx, &model.User{UserName: "prefixed", NewPassword: "nds_secret"})).To(Succeed()) - serve("u=prefixed", "p=nds_secret") - - Expect(next.called).To(BeTrue()) - _, ok := request.PlayerFrom(next.req.Context()) - Expect(ok).To(BeFalse()) - }) - - It("rejects another user's key with error 40", func() { - Expect(ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "pw"})).To(Succeed()) - serve("u=other", "p="+key) - - Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(next.called).To(BeFalse()) - }) - }) - }) - - When("failed attempts reach AuthRequestLimit", func() { - var cp http.Handler - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 3 - conf.Server.AuthWindowLength = time.Minute - cp = authenticate(ds)(next) - }) - - serve := func(r *http.Request) *httptest.ResponseRecorder { - next.called = false - rec := httptest.NewRecorder() - cp.ServeHTTP(rec, r) - return rec - } - failTimes := func(n int, params ...string) { - for range n { - Expect(serve(newGetRequest(params...)).Body.String()).To(ContainSubstring(`code="40"`)) - } - } - - It("rejects the correct password exactly like a wrong one", func() { - failTimes(3, "u=admin", "p=WRONG") - - rec := serve(newGetRequest("u=admin", "p=wordpass")) - - Expect(next.called).To(BeFalse()) - Expect(rec.Code).To(Equal(http.StatusOK)) - Expect(rec.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(rec.Header().Get("Retry-After")).To(BeEmpty()) - }) - - It("counts attempts against unknown usernames", func() { - failTimes(3, "u=newuser", "p=secret") - _ = ds.User().Put(ctx, &model.User{UserName: "newuser", NewPassword: "secret"}) - - serve(newGetRequest("u=newuser", "p=secret")) - Expect(next.called).To(BeFalse()) - }) - - It("treats usernames case-insensitively", func() { - failTimes(3, "u=ADMIN", "p=WRONG") - - serve(newGetRequest("u=admin", "p=wordpass")) - Expect(next.called).To(BeFalse()) - }) - - It("does not count successful logins", func() { - for range 10 { - serve(newGetRequest("u=admin", "p=wordpass")) - Expect(next.called).To(BeTrue()) - } - }) - - It("does not count server errors", func() { - userRepo := ds.User().(*tests.MockedUserRepo) - userRepo.Error = errors.New("db down") - failTimes(5, "u=admin", "p=wordpass") - userRepo.Error = nil - - serve(newGetRequest("u=admin", "p=wordpass")) - Expect(next.called).To(BeTrue()) - }) - - It("does not count server errors when a key is sent as the password", func() { - usr, _ := ds.User().FindByUsername(ctx, "admin") - playerRepo := ds.Player().(*tests.MockPlayerRepo) - Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed()) - key := "nds_0123456789abcdefghijkl" - Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed()) - - playerRepo.Error = errors.New("db down") - failTimes(5, "u=admin", "p="+key) - playerRepo.Error = nil - - serve(newGetRequest("u=admin", "p="+key)) - Expect(next.called).To(BeTrue()) - }) - - It("does not block other usernames from the same IP", func() { - _ = ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "otherpass"}) - failTimes(3, "u=admin", "p=WRONG") - - serve(newGetRequest("u=other", "p=otherpass")) - Expect(next.called).To(BeTrue()) - }) - - It("does not block the same username from another IP", func() { - failTimes(3, "u=admin", "p=WRONG") - - r := newGetRequest("u=admin", "p=wordpass") - r.RemoteAddr = "198.51.100.7:1234" - serve(r) - Expect(next.called).To(BeTrue()) - }) - - It("does not limit reverse proxy authentication", func() { - conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24" - conf.Server.ExtAuth.UserHeader = "Remote-User" - failTimes(3, "u=admin", "p=WRONG") - - r := newGetRequest() - r.Header.Add("Remote-User", "admin") - r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1")) - serve(r) - Expect(next.called).To(BeTrue()) - }) - - It("throttles a repeated bad key without locking out valid keys from the same IP", func() { - usr, _ := ds.User().FindByUsername(ctx, "admin") - playerRepo := ds.Player().(*tests.MockPlayerRepo) - Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed()) - key := "nds_0123456789abcdefghijkl" - Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed()) - - for range 3 { - Expect(serve(newGetRequest("apiKey=nds_bad")).Body.String()).To(ContainSubstring(`code="44"`)) - } - playerRepo.APIKeys["nds_bad"] = "player-1" - rec := serve(newGetRequest("apiKey=nds_bad")) - Expect(next.called).To(BeFalse()) - Expect(rec.Body.String()).To(ContainSubstring(`code="44"`)) - - serve(newGetRequest("apiKey=" + key)) - Expect(next.called).To(BeTrue()) - }) - - It("is disabled when AuthRequestLimit is 0", func() { - conf.Server.AuthRequestLimit = 0 - cp = authenticate(ds)(next) - failTimes(10, "u=admin", "p=WRONG") - - serve(newGetRequest("u=admin", "p=wordpass")) - Expect(next.called).To(BeTrue()) - }) - }) - - When("valid requests overlap", func() { - var gate *gatedUserRepo - var gatedDS model.DataStore - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 5 - conf.Server.AuthWindowLength = time.Minute - gate = &gatedUserRepo{ - UserRepository: ds.User(), - entered: make(chan struct{}, 64), - proceed: make(chan struct{}), - } - gatedDS = &gatedDataStore{DataStore: ds, users: gate} - }) - - It("lets every valid request through while checks are in flight", func() { - const burst = 6 - cp := authenticate(gatedDS)(&countingHandler{}) - var passed atomic.Int32 - var wg sync.WaitGroup - for range burst { - wg.Go(func() { - rec := httptest.NewRecorder() - cp.ServeHTTP(rec, newGetRequest("u=admin", "p=wordpass")) - if !strings.Contains(rec.Body.String(), `code="40"`) { - passed.Add(1) - } - }) - } - for range conf.Server.AuthRequestLimit { - Eventually(gate.entered).Should(Receive()) - } - close(gate.proceed) - wg.Wait() - - Expect(passed.Load()).To(Equal(int32(burst))) - }) - - It("caps concurrent credential checks for wrong passwords", func() { - cp := authenticate(gatedDS)(&countingHandler{}) - var wg sync.WaitGroup - for i := range 100 { - wg.Go(func() { - cp.ServeHTTP(httptest.NewRecorder(), newGetRequest("u=admin", fmt.Sprintf("p=wrong%d", i))) - }) - } - - limit := int32(conf.Server.AuthRequestLimit) - Eventually(gate.lookups.Load).Should(Equal(limit)) - Consistently(gate.lookups.Load, 100*time.Millisecond).Should(Equal(limit)) - close(gate.proceed) - wg.Wait() - }) - }) - }) - - 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() { @@ -670,24 +338,6 @@ var _ = Describe("Middlewares", func() { Expect(cookieStr).To(BeEmpty()) }) - Context("player bound by an API key", func() { - BeforeEach(func() { - r = r.WithContext(request.WithPlayer(r.Context(), model.Player{ID: "keyed"})) - gp := getPlayer(mockedPlayers)(next) - gp.ServeHTTP(w, r) - }) - - It("uses the key's player", func() { - Expect(mockedPlayers.touched).To(BeTrue()) - player, _ := request.PlayerFrom(next.req.Context()) - Expect(player.ID).To(Equal("keyed")) - }) - - It("does not set the player cookie", func() { - Expect(w.Header().Get("Set-Cookie")).To(BeEmpty()) - }) - }) - Context("PlayerId specified in Cookies", func() { BeforeEach(func() { cookie := &http.Cookie{ @@ -741,14 +391,14 @@ var _ = Describe("Middlewares", func() { var usr *model.User BeforeEach(func() { - ur := ds.User() - _ = ur.Put(ctx, &model.User{ + ur := ds.User(context.TODO()) + _ = ur.Put(&model.User{ UserName: "admin", NewPassword: "wordpass", }) var err error - usr, err = ur.FindByUsernameWithPassword(ctx, "admin") + usr, err = ur.FindByUsernameWithPassword("admin") if err != nil { panic(err) } @@ -790,7 +440,6 @@ var _ = Describe("Middlewares", func() { var validToken string BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) conf.Server.SessionTimeout = time.Minute auth.Init(ds) @@ -820,36 +469,6 @@ var _ = Describe("Middlewares", func() { Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) - - Context("JWT credentials", func() { - var usr *model.User - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SessionTimeout = time.Minute - auth.Init(ds) - usr = &model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1} - }) - - It("accepts an unscoped session token", func() { - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - Expect(validateCredentials(usr, "", "", "", tokenStr)).To(Succeed()) - }) - - It("rejects a jellyfin-scoped token", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) - }) - - It("rejects a token with a stale epoch", func() { - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - usr.TokenEpoch = 2 - Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) - }) - }) }) }) @@ -868,12 +487,6 @@ func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { type mockPlayers struct { core.Players transcoding *model.Transcoding - touched bool -} - -func (mp *mockPlayers) Touch(_ context.Context, plr model.Player, _, _, _ string) (*model.Player, *model.Transcoding, error) { - mp.touched = true - return &plr, mp.transcoding, nil } func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) { @@ -886,28 +499,3 @@ func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) } return &model.Player{ID: id}, mp.transcoding, nil } - -type gatedDataStore struct { - model.DataStore - users model.UserRepository -} - -func (g *gatedDataStore) User() model.UserRepository { return g.users } - -type gatedUserRepo struct { - model.UserRepository - entered chan struct{} - proceed chan struct{} - lookups atomic.Int32 -} - -func (g *gatedUserRepo) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) { - g.lookups.Add(1) - g.entered <- struct{}{} - <-g.proceed - return g.UserRepository.FindByUsernameWithPassword(ctx, username) -} - -type countingHandler struct{ calls atomic.Int32 } - -func (c *countingHandler) ServeHTTP(http.ResponseWriter, *http.Request) { c.calls.Add(1) } diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 21c407039..85edb1012 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -11,12 +11,10 @@ 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, 2}}, + {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, {Name: "playbackReport", Versions: []int32{1}}, - {Name: "topSongsByArtistId", Versions: []int32{1}}, - {Name: "apiKeyAuthentication", Versions: []int32{1}}, } if api.sonic != nil && api.sonic.HasProvider() { extensions = append(extensions, responses.OpenSubsonicExtension{ diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 8740c9971..3ccbf232e 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -44,7 +44,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) - It("should return the base 8 OpenSubsonicExtensions without sonicSimilarity", func() { + It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() { router.ServeHTTP(w, r) // Make sure the endpoint is public, by not passing any authentication @@ -55,15 +55,13 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(8), + 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, 2}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", Versions: []int32{1}}), )) Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), @@ -77,7 +75,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) }) - It("should return 9 extensions including sonicSimilarity", func() { + It("should return 7 extensions including sonicSimilarity", func() { router.ServeHTTP(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -87,16 +85,14 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(9), + 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, 2}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", Versions: []int32{1}}), )) }) }) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index e64fc9e82..a8c3da68c 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -5,12 +5,14 @@ import ( "errors" "fmt" "net/http" + "time" "github.com/navidrome/navidrome/conf" "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/gg" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" ) @@ -61,7 +63,7 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() p := req.Params(r) - songIds := p.Strings("songId") + songIds, _ := p.Strings("songId") playlistId, _ := p.String("playlistId") name, _ := p.String("name") if playlistId == "" && name == "" { @@ -98,7 +100,7 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - songsToAdd := p.Strings("songIdToAdd") + songsToAdd, _ := p.Strings("songIdToAdd") songIndexesToRemove, _ := p.Ints("songIndexToRemove") var plsName *string if s, err := p.String("name"); err == nil { @@ -132,7 +134,15 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.SongCount = int32(p.SongCount) pls.Duration = int32(p.Duration) pls.Created = p.CreatedAt - pls.Changed = p.UpdatedAt + if p.IsSmartPlaylist() { + if p.EvaluatedAt != nil { + pls.Changed = *p.EvaluatedAt + } else { + pls.Changed = time.Now() + } + } else { + pls.Changed = p.UpdatedAt + } player, ok := request.PlayerFrom(ctx) if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) { @@ -142,7 +152,7 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.Comment = p.Comment pls.Owner = p.OwnerName pls.Public = p.Public - pls.CoverArt = coverArtOrEmpty(p.CoverArtID(), p.ImageAbsent) + pls.CoverArt = p.CoverArtID().String() pls.OpenSubsonicPlaylist = buildOSPlaylist(ctx, p) return pls @@ -159,11 +169,11 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso pls.Readonly = true if p.EvaluatedAt != nil { - pls.ValidUntil = new(p.EvaluatedAt.Add(p.RefreshDelay())) + pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) } } else { user, ok := request.UserFrom(ctx) - pls.Readonly = !ok || p.OwnerID != user.ID || !p.TracksEditable() + pls.Readonly = !ok || p.OwnerID != user.ID } return &pls diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index c7775c0fa..1d5f6a70a 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -2,7 +2,6 @@ package subsonic import ( "context" - "encoding/json" "time" "github.com/navidrome/navidrome/conf" @@ -111,15 +110,6 @@ var _ = Describe("buildPlaylist", func() { Expect(result.Public).To(BeTrue()) Expect(result.Readonly).To(BeFalse()) }) - - It("is read-only for a synced playlist even as owner", func() { - ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) - playlist.Sync = true - - result := router.buildPlaylist(ctx, playlist) - - Expect(result.Readonly).To(BeTrue()) - }) }) Context("when minimal clients list is empty", func() { @@ -138,19 +128,6 @@ var _ = Describe("buildPlaylist", func() { }) }) - Context("artwork emission", func() { - It("suffixes coverArt with the content hash when resolved", func() { - playlist.ImageHash = "0123456789abcdef" - result := router.buildPlaylist(ctx, playlist) - Expect(result.CoverArt).To(Equal("pl-pls-1_0123456789abcdef")) - }) - It("omits coverArt when known absent", func() { - playlist.ImageAbsent = true - result := router.buildPlaylist(ctx, playlist) - Expect(result.CoverArt).To(BeEmpty()) - }) - }) - Context("with legacy client", func() { BeforeEach(func() { conf.Server.Subsonic.LegacyClients = "legacy-client" @@ -220,7 +197,7 @@ var _ = Describe("buildPlaylist", func() { Expect(result.SongCount).To(Equal(int32(10))) Expect(result.Duration).To(Equal(int32(600))) Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + Expect(result.Changed).To(Equal(evaluatedAt)) // These should not be set Expect(result.Comment).To(BeEmpty()) @@ -245,7 +222,7 @@ var _ = Describe("buildPlaylist", func() { Expect(result.SongCount).To(Equal(int32(10))) Expect(result.Duration).To(Equal(int32(600))) Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + Expect(result.Changed).To(Equal(*playlist.EvaluatedAt)) Expect(result.Comment).To(Equal("Test comment")) Expect(result.Owner).To(Equal("admin")) Expect(result.Public).To(BeTrue()) @@ -270,56 +247,6 @@ var _ = Describe("buildPlaylist", func() { Expect(result.OpenSubsonicPlaylist).To(BeNil()) }) }) - - Context("when it was never evaluated", func() { - BeforeEach(func() { - playlist.EvaluatedAt = nil - player := model.Player{Client: "regular-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("omits validUntil but still reports changed", func() { - result := router.buildPlaylist(ctx, playlist) - - Expect(result.ValidUntil).To(BeNil()) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) - }) - }) - - Context("with a per-playlist refreshDelay", func() { - BeforeEach(func() { - playlist.Rules.RefreshDelay = 24 * time.Hour - player := model.Player{Client: "regular-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("computes validUntil from the playlist's own delay", func() { - result := router.buildPlaylist(ctx, playlist) - expected := evaluatedAt.Add(24 * time.Hour) - Expect(result.ValidUntil).To(Equal(&expected)) - }) - }) - }) - - 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")) - }) }) }) diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 2c4033467..4fbd6a53d 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -32,7 +32,7 @@ func (api *Router) CreateInternetRadio(r *http.Request) (*responses.Subsonic, er Name: name, } - err = api.ds.Radio().Put(ctx, radio) + err = api.ds.Radio(ctx).Put(radio) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (api *Router) DeleteInternetRadio(r *http.Request) (*responses.Subsonic, er return nil, err } - err = api.ds.Radio().Delete(r.Context(), id) + err = api.ds.Radio(r.Context()).Delete(id) if err != nil { return nil, err } @@ -56,7 +56,7 @@ func (api *Router) DeleteInternetRadio(r *http.Request) (*responses.Subsonic, er func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - radios, err := api.ds.Radio().GetAll(ctx, model.QueryOptions{Sort: "name"}) + radios, err := api.ds.Radio(ctx).GetAll(model.QueryOptions{Sort: "name"}) if err != nil { return nil, err } @@ -74,10 +74,10 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) { continue } - // Add coverArt if not legacy client; only radios have an uploaded image as their sole art source + // Add coverArt if not legacy client var coverArt string if g.UploadedImage != "" { - coverArt = coverArtOrEmpty(g.CoverArtID(), g.ImageAbsent) + coverArt = g.CoverArtID().String() } res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{ CoverArt: coverArt, @@ -119,7 +119,7 @@ func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, er Name: name, } - err = api.ds.Radio().Put(ctx, radio, "StreamUrl", "HomePageUrl", "Name") + err = api.ds.Radio(ctx).Put(radio, "StreamUrl", "HomePageUrl", "Name") if err != nil { return nil, err } diff --git a/server/subsonic/radio_test.go b/server/subsonic/radio_test.go index aa805abbf..e959ebe29 100644 --- a/server/subsonic/radio_test.go +++ b/server/subsonic/radio_test.go @@ -71,25 +71,10 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil()) Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) }) - - It("suffixes coverArt with the content hash when resolved and omits it when known absent", func() { - radioRepo.All = model.Radios{ - {ID: "rd-1", Name: "Radio 1", UploadedImage: "rd-1_cover.jpg", ItemImage: model.ItemImage{ImageHash: "0123456789abcdef"}}, - {ID: "rd-2", Name: "Radio 2", UploadedImage: "rd-2_cover.jpg", ItemImage: model.ItemImage{ImageAbsent: true}}, - } - r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) - r = r.WithContext(ctx) - - response, err := api.GetInternetRadios(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0123456789abcdef")) - Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) - }) }) Context("with a legacy client", func() { @@ -144,7 +129,7 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) }) }) 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 d6b195f58..9d9ae2195 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 @@ -59,9 +59,7 @@ "explicitStatus": "explicit", "groupings": [ "Soundtrack" - ], - "works": [], - "movements": [] + ] } ] } 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 f776c7535..a07914344 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,9 +8,7 @@ "id": "1", "name": "album", "artist": "artist", - "songCount": 0, "duration": 292, - "created": "0001-01-01T00:00:00Z", "genre": "rock", "userRating": 4, "genres": [ @@ -171,9 +169,7 @@ "groupings": [ "Soundtrack", "Live" - ], - "works": [], - "movements": [] + ] }, { "id": "2", @@ -219,9 +215,7 @@ "contributors": [], "displayComposer": "", "explicitStatus": "", - "groupings": [], - "works": [], - "movements": [] + "groupings": [] } ] } 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 16a7748aa..6c96391bc 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 @@ - + 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 030502618..14e96939e 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,8 +7,6 @@ "album": { "id": "", "name": "", - "songCount": 0, - "duration": 0, - "created": "0001-01-01T00:00:00Z" + "duration": 0 } } 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 0d3882080..868265347 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 d3964663b..446368fa5 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,9 +7,7 @@ "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 0d3882080..868265347 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 fef60c9b1..4c0ea6c68 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 @@ -114,22 +114,6 @@ "groupings": [ "Soundtrack", "Live" - ], - "works": [ - { - "name": "Symphony No. 5", - "musicBrainzId": "mbz-work-1" - }, - { - "name": "Encore" - } - ], - "movements": [ - { - "name": "I. Allegro", - "number": 1, - "count": 4 - } ] }, { @@ -162,9 +146,7 @@ "contributors": [], "displayComposer": "", "explicitStatus": "", - "groupings": [], - "works": [], - "movements": [] + "groupings": [] } ], "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 b626fd6ea..ddceb67d4 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 @@ -26,9 +26,6 @@ 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 ea23dc5d6..9a9ab1ff6 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 @@ -29,9 +29,7 @@ "contributors": [], "displayComposer": "", "explicitStatus": "", - "groupings": [], - "works": [], - "movements": [] + "groupings": [] } ], "id": "", diff --git a/server/subsonic/responses/.snapshots/Responses TokenInfo should match .JSON b/server/subsonic/responses/.snapshots/Responses TokenInfo should match .JSON deleted file mode 100644 index f2e251f49..000000000 --- a/server/subsonic/responses/.snapshots/Responses TokenInfo should match .JSON +++ /dev/null @@ -1,10 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "tokenInfo": { - "username": "deluan" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses TokenInfo should match .XML b/server/subsonic/responses/.snapshots/Responses TokenInfo should match .XML deleted file mode 100644 index 7ea786bb9..000000000 --- a/server/subsonic/responses/.snapshots/Responses TokenInfo should match .XML +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/server/subsonic/responses/errors.go b/server/subsonic/responses/errors.go index 9c9dd10f6..42e5427b3 100644 --- a/server/subsonic/responses/errors.go +++ b/server/subsonic/responses/errors.go @@ -1,29 +1,25 @@ package responses const ( - ErrorGeneric int32 = 0 - ErrorMissingParameter int32 = 10 - ErrorClientTooOld int32 = 20 - ErrorServerTooOld int32 = 30 - ErrorAuthenticationFail int32 = 40 - ErrorMultipleAuthMechanismsProvided int32 = 43 - ErrorInvalidAPIKey int32 = 44 - ErrorAuthorizationFail int32 = 50 - ErrorTrialExpired int32 = 60 - ErrorDataNotFound int32 = 70 + ErrorGeneric int32 = 0 + ErrorMissingParameter int32 = 10 + ErrorClientTooOld int32 = 20 + ErrorServerTooOld int32 = 30 + ErrorAuthenticationFail int32 = 40 + ErrorAuthorizationFail int32 = 50 + ErrorTrialExpired int32 = 60 + ErrorDataNotFound int32 = 70 ) -var errors = map[int32]string{ //nolint:gosec // G101 false positive: error messages, not credentials - ErrorGeneric: "A generic error", - ErrorMissingParameter: "Required parameter is missing", - ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade", - ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade", - ErrorAuthenticationFail: "Wrong username or password", - ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided", - ErrorInvalidAPIKey: "Invalid API key", - ErrorAuthorizationFail: "User is not authorized for the given operation", - ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details", - ErrorDataNotFound: "The requested data was not found", +var errors = map[int32]string{ + ErrorGeneric: "A generic error", + ErrorMissingParameter: "Required parameter is missing", + ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade", + ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade", + ErrorAuthenticationFail: "Wrong username or password", + ErrorAuthorizationFail: "User is not authorized for the given operation", + ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details", + ErrorDataNotFound: "The requested data was not found", } func ErrorMsg(code int32) string { diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 51d0020b2..4165669df 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -63,7 +63,6 @@ type Subsonic struct { PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` SonicMatches *Array[SonicMatch] `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"` - TokenInfo *TokenInfo `xml:"tokenInfo,omitempty" json:"tokenInfo,omitempty"` } const ( @@ -191,8 +190,6 @@ type OpenSubsonicChild struct { 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 { @@ -254,10 +251,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" json:"songCount"` + SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` Duration int32 `xml:"duration,attr" json:"duration"` PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` - Created time.Time `xml:"created,attr" json:"created"` + Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` 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"` @@ -550,39 +547,13 @@ 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"` - 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"` + 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"` } type StructuredLyrics []StructuredLyric @@ -597,25 +568,10 @@ type OpenSubsonicExtension struct { type OpenSubsonicExtensions []OpenSubsonicExtension -type TokenInfo struct { - Username string `xml:"username,attr" json:"username"` -} - 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 027ac11d5..0b72fab19 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -8,6 +8,7 @@ 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" ) @@ -93,10 +94,11 @@ 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: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), + Starred: &t, UserRating: 3, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", } @@ -131,10 +133,11 @@ 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: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), + Starred: &t, UserRating: 3, AlbumCount: 2, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", @@ -155,10 +158,11 @@ 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: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), + Starred: &t, UserRating: 3, AlbumCount: 2, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", @@ -207,11 +211,12 @@ 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: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), + Duration: 146, BitRate: 320, Starred: &t, } child[0].OpenSubsonicChild = &OpenSubsonicChild{ Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, @@ -220,7 +225,7 @@ var _ = Describe("Responses", func() { BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, Moods: []string{"happy", "sad"}, 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)}, + 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)}, DisplayArtist: "artist 1 & artist 2", Artists: []ArtistID3Ref{ {Id: "1", Name: "artist1"}, @@ -239,16 +244,9 @@ 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: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, + 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)}, } response.Directory.Child = child }) @@ -324,7 +322,7 @@ var _ = Describe("Responses", func() { Isrc: []string{"ISRC-1"}, Moods: []string{"happy", "sad"}, 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)}, + 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)}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, DisplayArtist: "artist1 & artist2", Artists: []ArtistID3Ref{ @@ -344,7 +342,7 @@ var _ = Describe("Responses", func() { ExplicitStatus: "clean", } songs[1].OpenSubsonicChild = &OpenSubsonicChild{ - 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)}, + 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)}, } response.AlbumWithSongsID3.AlbumID3 = album response.AlbumWithSongsID3.Song = songs @@ -806,7 +804,7 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { response.PlayQueueByIndex.Username = "user1" - response.PlayQueueByIndex.CurrentIndex = new(0) + response.PlayQueueByIndex.CurrentIndex = gg.P(0) response.PlayQueueByIndex.Position = 243 response.PlayQueueByIndex.Changed = time.Time{} response.PlayQueueByIndex.ChangedBy = "a_client" @@ -1015,20 +1013,6 @@ var _ = Describe("Responses", func() { }) }) - Describe("TokenInfo", func() { - BeforeEach(func() { - response.OpenSubsonic = true - response.TokenInfo = &TokenInfo{Username: "deluan"} - }) - - It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) - Describe("InternetRadioStations", func() { BeforeEach(func() { response.InternetRadioStations = &InternetRadioStations{} diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index fb370fc9d..fd7e29587 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -42,7 +42,7 @@ func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) { return sp, nil } -type searchFunc[T any] func(ctx context.Context, q string, options ...model.QueryOptions) (T, error) +type searchFunc[T any] func(q string, options ...model.QueryOptions) (T, error) func callSearch[T any](ctx context.Context, s searchFunc[T], q string, options model.QueryOptions, result *T) func() error { return func() error { @@ -52,7 +52,7 @@ func callSearch[T any](ctx context.Context, s searchFunc[T], q string, options m typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.") var err error start := time.Now() - *result, err = s(ctx, q, options) + *result, err = s(q, options) if err != nil { log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err) } else { @@ -74,14 +74,14 @@ 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_id": musicFolderIds} + artistOpts.Filters = Eq{"library_artist.library_id": musicFolderIds} } // Run searches in parallel g, ctx := errgroup.WithContext(ctx) - g.Go(callSearch(ctx, api.ds.MediaFile().Search, q, songOpts, &mediaFiles)) - g.Go(callSearch(ctx, api.ds.Album().Search, q, albumOpts, &albums)) - g.Go(callSearch(ctx, api.ds.Artist().Search, q, artistOpts, &artists)) + g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, songOpts, &mediaFiles)) + g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, albumOpts, &albums)) + g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, artistOpts, &artists)) err := g.Wait() if err == nil { log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists", @@ -110,13 +110,11 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { searchResult2 := &responses.SearchResult2{} searchResult2.Artist = slice.Map(as, func(artist model.Artist) responses.Artist { a := responses.Artist{ - Id: artist.ID, - Name: artist.Name, - UserRating: int32(artist.Rating), - CoverArt: coverArtOrEmpty(artist.CoverArtID(), artist.ImageAbsent), - } - if !artist.ImageAbsent { - a.ArtistImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) + Id: artist.ID, + Name: artist.Name, + UserRating: int32(artist.Rating), + CoverArt: artist.CoverArtID().String(), + ArtistImageUrl: publicurl.ImageURL(r, artist.CoverArtID(), 600), } if artist.Starred { a.Starred = artist.StarredAt diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index 177fa2133..9a9c6af6f 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -1,8 +1,6 @@ package subsonic import ( - "net/http" - "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" @@ -26,9 +24,9 @@ var _ = Describe("Search", func() { router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options - mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) - mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) - mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) + mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) + mockArtistRepo = ds.Artist(nil).(*tests.MockArtistRepo) + mockMediaFileRepo = ds.MediaFile(nil).(*tests.MockMediaFileRepo) }) Context("musicFolderId parameter", func() { @@ -41,17 +39,12 @@ var _ = Describe("Search", func() { } Describe("Search2", 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. + It("should accept musicFolderId parameter", 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: 2, Name: "Library 2"}, - }, + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, }) r = r.WithContext(ctx) @@ -61,13 +54,14 @@ 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(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) }) - It("applies no library filter when musicFolderId is not provided", func() { - r := newGetRequest("query=test") // no musicFolderId → all accessible libraries + It("should return results from all accessible libraries when musicFolderId is not provided", func() { + r := newGetRequest("query=test") ctx := request.WithUser(r.Context(), model.User{ ID: "user1", UserName: "testuser", @@ -85,9 +79,10 @@ 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(mockMediaFileRepo.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) }) It("should return empty results when user has no accessible libraries", func() { @@ -127,15 +122,12 @@ var _ = Describe("Search", func() { }) Describe("Search3", func() { - It("scopes all entity types to the requested libraries", func() { + It("should accept musicFolderId parameter", 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: 2, Name: "Library 2"}, - }, + ID: "user1", + UserName: "testuser", + Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, }) r = r.WithContext(ctx) @@ -145,13 +137,14 @@ 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(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) }) - It("applies no library filter when musicFolderId is not provided", func() { - r := newGetRequest("query=test") // no musicFolderId → all accessible libraries + It("should return results from all accessible libraries when musicFolderId is not provided", func() { + r := newGetRequest("query=test") ctx := request.WithUser(r.Context(), model.User{ ID: "user1", UserName: "testuser", @@ -169,9 +162,10 @@ 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(mockMediaFileRepo.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) }) It("should return empty results when user has no accessible libraries", func() { @@ -211,32 +205,4 @@ var _ = Describe("Search", func() { }) }) }) - - Describe("artwork emission", func() { - newSearchRequest := func() *http.Request { - r := newGetRequest("query=test") - ctx := request.WithUser(r.Context(), model.User{ID: "user1", Libraries: []model.Library{{ID: 1}}}) - return r.WithContext(ctx) - } - - It("omits coverArt and imageUrl for a known-absent artist", func() { - mockArtistRepo.SetData(model.Artists{ - {ID: "ar-1", Name: "Absent Artist", ItemImage: model.ItemImage{ImageAbsent: true}}, - }) - resp, err := router.Search2(newSearchRequest()) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.SearchResult2.Artist).To(HaveLen(1)) - Expect(resp.SearchResult2.Artist[0].CoverArt).To(BeEmpty()) - Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).To(BeEmpty()) - }) - - It("emits coverArt and imageUrl for an unresolved artist", func() { - mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Unresolved Artist"}}) - resp, err := router.Search2(newSearchRequest()) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.SearchResult2.Artist).To(HaveLen(1)) - Expect(resp.SearchResult2.Artist[0].CoverArt).To(Equal("ar-ar-1")) - Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).ToNot(BeEmpty()) - }) - }) }) diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index e5f4f2a54..9cc8d7097 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -1,12 +1,11 @@ package subsonic import ( - "cmp" "net/http" "strings" "time" - "github.com/navidrome/navidrome/conf" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" @@ -15,8 +14,8 @@ import ( ) func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) { - repo := api.share.Repository() - shares, err := repo.GetAll(r.Context(), model.QueryOptions{Sort: "created_at desc"}) + repo := api.share.NewRepository(r.Context()).(model.ShareRepository) + shares, err := repo.GetAll(model.QueryOptions{Sort: "created_at desc"}) if err != nil { return nil, err } @@ -32,7 +31,7 @@ func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) { func (api *Router) buildShare(r *http.Request, share model.Share) responses.Share { resp := responses.Share{ ID: share.ID, - Url: public.ShareURL(r.Context(), share.ID), + Url: public.ShareURL(r, share.ID), Description: share.Description, Username: share.Username, Created: share.CreatedAt, @@ -53,26 +52,27 @@ func (api *Router) buildShare(r *http.Request, share model.Share) responses.Shar func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids := p.Strings("id") - if len(ids) == 0 { - return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") - } - - description, _ := p.String("description") - repo := api.share.Repository() - share := &model.Share{ - Description: description, - Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads), - ExpiresAt: new(p.TimeOr("expires", time.Time{})), - ResourceIDs: strings.Join(ids, ","), - } - - id, err := repo.Save(r.Context(), share) + ids, err := p.Strings("id") if err != nil { return nil, err } - share, err = repo.Get(r.Context(), id) + description, _ := p.String("description") + expires := p.TimeOr("expires", time.Time{}) + + repo := api.share.NewRepository(r.Context()) + share := &model.Share{ + Description: description, + ExpiresAt: &expires, + ResourceIDs: strings.Join(ids, ","), + } + + id, err := repo.(rest.Persistable).Save(share) + if err != nil { + return nil, err + } + + share, err = repo.(model.ShareRepository).Get(id) if err != nil { return nil, err } @@ -89,29 +89,17 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { return nil, err } - repo := api.share.Repository() - - // The update always writes description and downloadable, so read back the - // stored value for whichever one the client omitted. - description := p.StringPtr("description") - downloadable := p.BoolPtr("downloadable") - if description == nil || downloadable == nil { - cur, err := repo.Read(r.Context(), id) - if err != nil { - return nil, err - } - description = cmp.Or(description, &cur.Description) - downloadable = cmp.Or(downloadable, &cur.Downloadable) - } + description, _ := p.String("description") + expires := p.TimeOr("expires", time.Time{}) + repo := api.share.NewRepository(r.Context()) share := &model.Share{ - ID: id, - Description: *description, - Downloadable: *downloadable, - ExpiresAt: new(p.TimeOr("expires", time.Time{})), + ID: id, + Description: description, + ExpiresAt: &expires, } - err = repo.Update(r.Context(), id, *share) + err = repo.(rest.Persistable).Update(id, share) if err != nil { return nil, err } @@ -126,8 +114,8 @@ func (api *Router) DeleteShare(r *http.Request) (*responses.Subsonic, error) { return nil, err } - repo := api.share.Repository() - err = repo.Delete(r.Context(), id) + repo := api.share.NewRepository(r.Context()) + err = repo.(rest.Persistable).Delete(id) if err != nil { return nil, err } diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index fd26ccc4d..b49af2b24 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -1,19 +1,17 @@ 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" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/str" ) func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { @@ -27,7 +25,7 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su format, _ := p.String("format") timeOffset := p.IntOr("timeOffset", 0) - mf, err := api.ds.MediaFile().Get(ctx, id) + mf, err := api.ds.MediaFile(ctx).Get(id) if err != nil { return nil, err } @@ -93,7 +91,9 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. } setHeaders := func(name string) { - w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(name+".zip")) + name = strings.ReplaceAll(name, ",", "_") + disposition := fmt.Sprintf("attachment; filename=\"%s.zip\"", name) + w.Header().Set("Content-Disposition", disposition) w.Header().Set("Content-Type", "application/zip") } @@ -112,34 +112,21 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. } }() - w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(stream.Name())) + disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name()) + w.Header().Set("Content-Disposition", disposition) _, err = stream.Serve(ctx, w, r) return nil, err case *model.Album: setHeaders(v.Name) - return nil, handleArchiveErr(ctx, id, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)) + return nil, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) case *model.Artist: setHeaders(v.Name) - return nil, handleArchiveErr(ctx, id, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)) + return nil, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) case *model.Playlist: setHeaders(v.Name) - return nil, handleArchiveErr(ctx, id, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)) + return nil, 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/system.go b/server/subsonic/system.go index 4a59e7898..e14099942 100644 --- a/server/subsonic/system.go +++ b/server/subsonic/system.go @@ -3,7 +3,6 @@ package subsonic import ( "net/http" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" ) @@ -16,10 +15,3 @@ func (api *Router) GetLicense(_ *http.Request) (*responses.Subsonic, error) { response.License = &responses.License{Valid: true} return response, nil } - -func (api *Router) TokenInfo(r *http.Request) (*responses.Subsonic, error) { - user, _ := request.UserFrom(r.Context()) - response := newResponse() - response.TokenInfo = &responses.TokenInfo{Username: user.UserName} - return response, nil -} diff --git a/server/subsonic/system_test.go b/server/subsonic/system_test.go deleted file mode 100644 index a8c85238e..000000000 --- a/server/subsonic/system_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package subsonic - -import ( - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("TokenInfo", func() { - It("returns the authenticated username", func() { - api := &Router{} - r := httptest.NewRequest("GET", "/tokenInfo", nil) - r = r.WithContext(request.WithUser(r.Context(), model.User{UserName: "deluan"})) - - resp, err := api.TokenInfo(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.TokenInfo.Username).To(Equal("deluan")) - }) -}) diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 7a011a616..4e494b324 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -8,11 +8,9 @@ import ( "slices" "strconv" - "github.com/navidrome/navidrome/core/ffmpeg" "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" ) @@ -280,37 +278,8 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return stream.IsAACCodec(p.Container) }) - player, hasPlayer := request.PlayerFrom(ctx) - - // Honor the player's forced transcoding format, falling back to normal - // negotiation when the client can't play it (issue #5583). - maxBitRate := 0 - if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - if clientInfo.ForceFormat(trc.TargetFormat) { - // DirectPlayProfile carries no bitrate, so this ceiling is the only - // thing keeping an over-bitrate source out of direct play. - maxBitRate = trc.DefaultBitRate - } else { - clientName := clientInfo.Name - if hasPlayer && player.Client != "" { - clientName = player.Client - } - log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation", - "forcedFormat", trc.TargetFormat, "client", clientName) - } - } - - // The player's own MaxBitRate outranks the forced-format default (issue #5583). - if hasPlayer && player.MaxBitRate > 0 { - maxBitRate = player.MaxBitRate - } - if clientInfo.CapBitrate(maxBitRate) { - log.Debug(ctx, "Applied bitrate ceiling to transcode decision", - "maxBitRate", maxBitRate, "client", clientInfo.Name) - } - // Get media file - mf, err := api.ds.MediaFile().Get(ctx, mediaID) + mf, err := api.ds.MediaFile(ctx).Get(mediaID) if err != nil { if errors.Is(err, model.ErrNotFound) { return nil, newError(responses.ErrorDataNotFound, "media file not found: %s", mediaID) @@ -323,8 +292,7 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, stream.TranscodeOptions{}) if err != nil { log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err) - code, reason := transcodeFailure(err) - return nil, newError(code, "failed to make transcode decision: %s", reason) + return nil, newError(responses.ErrorGeneric, "failed to make transcode decision") } // Only create a token when there is a valid playback path @@ -355,19 +323,6 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return response, nil } -// transcodeFailure maps a decision error to a Subsonic error code and a reason -// safe to send to clients, omitting server file paths. -func transcodeFailure(err error) (int32, string) { - pe, ok := errors.AsType[*ffmpeg.ProbeError](err) - if !ok { - return responses.ErrorGeneric, "internal error" - } - if pe.NotFound { - return responses.ErrorDataNotFound, pe.SafeReason() - } - return responses.ErrorGeneric, pe.SafeReason() -} - // GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint. // It streams media using the decision encoded in the transcodeParams JWT token. // All errors are returned as proper HTTP status codes (not Subsonic error responses). @@ -399,7 +354,7 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* } // Fetch the media file - mf, err := api.ds.MediaFile().Get(ctx, mediaID) + mf, err := api.ds.MediaFile(ctx).Get(mediaID) if err != nil { if errors.Is(err, model.ErrNotFound) { http.Error(w, "Not Found", http.StatusNotFound) @@ -415,7 +370,6 @@ 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 1f2fdfcef..adc7b7600 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -4,16 +4,11 @@ import ( "bytes" "context" "errors" - "fmt" - "io/fs" "net/http" "net/http/httptest" - "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -81,47 +76,6 @@ var _ = Describe("Transcode endpoints", func() { Expect(err.Error()).To(ContainSubstring("error retrieving media file")) }) - It("enriches the decision error with the reason, without leaking the file path", func() { - mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) - mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", - &ffmpeg.ProbeError{Path: "/music/secret/foo.flac", Reason: "the file: Invalid data found when processing input"}) - r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to make transcode decision")) - Expect(err.Error()).To(ContainSubstring("Invalid data found when processing input")) - Expect(err.Error()).ToNot(ContainSubstring("/music/secret")) - var subErr subError - Expect(errors.As(err, &subErr)).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorGeneric)) - }) - - It("returns ErrorDataNotFound when the source file is missing on disk", func() { - mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) - mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", - &ffmpeg.ProbeError{Path: "/music/gone.flac", Reason: "file not found", NotFound: true}) - r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("file not found")) - var subErr subError - Expect(errors.As(err, &subErr)).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorDataNotFound)) - }) - - It("keeps ErrorGeneric when ffprobe is missing, even though the cause wraps fs.ErrNotExist", func() { - mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) - pe := &ffmpeg.ProbeError{Path: "/music/song.flac", Reason: "could not read file"} - mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w (%w)", pe, fs.ErrNotExist) - Expect(errors.Is(mockTD.decisionErr, fs.ErrNotExist)).To(BeTrue()) - r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - var subErr subError - Expect(errors.As(err, &subErr)).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorGeneric)) - }) - It("returns error when body is empty", func() { r := newJSONPostRequest("mediaId=song-1&mediaType=song", "") _, err := router.GetTranscodeDecision(w, r) @@ -251,7 +205,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: new(24)}, + {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}, }) mockTD.decision = &stream.TranscodeDecision{ MediaID: "song-2", @@ -280,184 +234,6 @@ 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 narrows direct play to it", 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(ConsistOf(stream.DirectPlayProfile{ - Containers: []string{"ogg"}, - AudioCodecs: []string{"opus"}, - Protocols: []string{"http"}, - })) - }) - - 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)) - }) - - withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request { - ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate}) - ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate}) - return r.WithContext(ctx) - } - - It("applies the transcoding default bitrate when the player sets no maxBitRate", func() { - body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` - r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) - Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192)) - }) - - It("prefers the player maxBitRate over the transcoding default bitrate", func() { - body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` - r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) - }) - - It("ignores the transcoding default bitrate when the forced format is unsupported", func() { - body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` - r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero()) - }) - }) }) Describe("GetTranscodeStream", func() { @@ -602,7 +378,6 @@ func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { // mockTranscodeDecision is a test double for stream.TranscodeDecider type mockTranscodeDecision struct { decision *stream.TranscodeDecision - decisionErr error token string tokenErr error resolvedReq stream.Request @@ -612,9 +387,6 @@ type mockTranscodeDecision struct { func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) { m.capturedClient = ci - if m.decisionErr != nil { - return nil, m.decisionErr - } if m.decision != nil { return m.decision, nil } @@ -625,10 +397,6 @@ func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.Media return stream.Request{Format: "raw"} } -func (m *mockTranscodeDecision) ResolveClientRequest(context.Context, *model.MediaFile, *stream.ClientInfo, int) stream.Request { - return stream.Request{Format: "raw"} -} - func (m *mockTranscodeDecision) CreateTranscodeParams(_ *stream.TranscodeDecision) (string, error) { return m.token, m.tokenErr } diff --git a/server/subsonic/users.go b/server/subsonic/users.go index acf8de3e8..8b7406b60 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -46,7 +46,8 @@ func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorAuthorizationFail) } response := newResponse() - response.User = new(buildUserResponse(loggedUser)) + user := buildUserResponse(loggedUser) + response.User = &user return response, nil } diff --git a/server/throttle_backlog.go b/server/throttle_backlog.go index 0d31d289e..c3672fd1e 100644 --- a/server/throttle_backlog.go +++ b/server/throttle_backlog.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "errors" - "maps" "net/http" "sync" "time" @@ -77,7 +76,9 @@ func (t *requestThrottle) handler(next http.Handler) http.Handler { next.ServeHTTP(buf, r) }() - maps.Copy(w.Header(), buf.header) + for k, v := range buf.header { + w.Header()[k] = v + } if buf.code > 0 { w.WriteHeader(buf.code) } diff --git a/tests/dns_stub.go b/tests/dns_stub.go deleted file mode 100644 index 7cd7faf6d..000000000 --- a/tests/dns_stub.go +++ /dev/null @@ -1,54 +0,0 @@ -package tests - -import ( - "context" - "encoding/binary" - "io" - "net" - "net/netip" - - "golang.org/x/net/dns/dnsmessage" -) - -// StubResolver returns a net.Resolver that answers A queries from records (absolute name to IPv4, -// e.g. "localhost." to "127.0.0.1") instead of the system DNS. Other names do not resolve. -func StubResolver(records map[string]string) *net.Resolver { - return &net.Resolver{ - PreferGo: true, - Dial: func(context.Context, string, string) (net.Conn, error) { - client, server := net.Pipe() - go serveStubDNS(server, records) - return client, nil - }, - } -} - -// net.Pipe is not a PacketConn, so the resolver sends one query framed with a 2-byte length prefix. -func serveStubDNS(conn net.Conn, records map[string]string) { - defer conn.Close() - var size [2]byte - if _, err := io.ReadFull(conn, size[:]); err != nil { - return - } - buf := make([]byte, binary.BigEndian.Uint16(size[:])) - if _, err := io.ReadFull(conn, buf); err != nil { - return - } - var msg dnsmessage.Message - if err := msg.Unpack(buf); err != nil || len(msg.Questions) != 1 { - return - } - msg.Response = true - q := msg.Questions[0] - if ip, err := netip.ParseAddr(records[q.Name.String()]); err == nil && q.Type == dnsmessage.TypeA { - msg.Answers = []dnsmessage.Resource{{ - Header: dnsmessage.ResourceHeader{Name: q.Name, Type: q.Type, Class: q.Class}, - Body: &dnsmessage.AResource{A: ip.As4()}, - }} - } - resp, err := msg.Pack() - if err != nil { - return - } - _, _ = conn.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(resp))), resp...)) -} diff --git a/tests/fixtures/bom-test.ttml b/tests/fixtures/bom-test.ttml deleted file mode 100644 index 319ab1f07..000000000 --- a/tests/fixtures/bom-test.ttml +++ /dev/null @@ -1,2 +0,0 @@ - -

BOM test line

diff --git a/tests/fixtures/bom-utf16-test.ttml b/tests/fixtures/bom-utf16-test.ttml deleted file mode 100644 index a5621ef5d..000000000 Binary files a/tests/fixtures/bom-utf16-test.ttml and /dev/null differ diff --git a/tests/fixtures/lastfm.artist.page.challenge.html b/tests/fixtures/lastfm.artist.page.challenge.html deleted file mode 100644 index f431891bd..000000000 --- a/tests/fixtures/lastfm.artist.page.challenge.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - Client Challenge - - - - - - - - diff --git a/tests/fixtures/lyrics/auld-lang-syne.elrc b/tests/fixtures/lyrics/auld-lang-syne.elrc deleted file mode 100644 index 41342d910..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.elrc +++ /dev/null @@ -1,27 +0,0 @@ -[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 deleted file mode 100644 index 56021870a..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.lrc +++ /dev/null @@ -1,28 +0,0 @@ -[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 deleted file mode 100644 index 116bec0bf..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.srt +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index a08be29e2..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.ttml +++ /dev/null @@ -1,31 +0,0 @@ - - - -
-

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 deleted file mode 100644 index 42ab8330e..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.txt +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index ca2a3d32e..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.yaml +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 120000 index 79c5d6f02..000000000 --- a/tests/fixtures/symlink_chain/evil1.mp3 +++ /dev/null @@ -1 +0,0 @@ -../index.html \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil2.mp3 b/tests/fixtures/symlink_chain/evil2.mp3 deleted file mode 120000 index 56d18ad24..000000000 --- a/tests/fixtures/symlink_chain/evil2.mp3 +++ /dev/null @@ -1 +0,0 @@ -evil1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil3.mp3 b/tests/fixtures/symlink_chain/evil3.mp3 deleted file mode 120000 index e1cac02e9..000000000 --- a/tests/fixtures/symlink_chain/evil3.mp3 +++ /dev/null @@ -1 +0,0 @@ -evil2.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level1.mp3 b/tests/fixtures/symlink_chain/level1.mp3 deleted file mode 120000 index 887033521..000000000 --- a/tests/fixtures/symlink_chain/level1.mp3 +++ /dev/null @@ -1 +0,0 @@ -../test.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level2.mp3 b/tests/fixtures/symlink_chain/level2.mp3 deleted file mode 120000 index eca2115ee..000000000 --- a/tests/fixtures/symlink_chain/level2.mp3 +++ /dev/null @@ -1 +0,0 @@ -level1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level3.mp3 b/tests/fixtures/symlink_chain/level3.mp3 deleted file mode 120000 index dd72f3cca..000000000 --- a/tests/fixtures/symlink_chain/level3.mp3 +++ /dev/null @@ -1 +0,0 @@ -level2.mp3 \ No newline at end of file diff --git a/tests/fixtures/test-enhanced.lrc b/tests/fixtures/test-enhanced.lrc deleted file mode 100644 index 8f7b60f8c..000000000 --- a/tests/fixtures/test-enhanced.lrc +++ /dev/null @@ -1,6 +0,0 @@ -[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 deleted file mode 100644 index 84190a3b0..000000000 --- a/tests/fixtures/test-instrumental.yaml +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index c0243c18f..000000000 --- a/tests/fixtures/test-metadata.ttml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - Hola - - - - - konni - - - - - - -
-

こんにちは

-

こんばんは

-
- -
diff --git a/tests/fixtures/test-overlapping.yaml b/tests/fixtures/test-overlapping.yaml deleted file mode 100644 index c1f95a87b..000000000 --- a/tests/fixtures/test-overlapping.yaml +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 625098d6a..000000000 --- a/tests/fixtures/test-words.yaml +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 01c3d2cdd..000000000 --- a/tests/fixtures/test.elrc +++ /dev/null @@ -1,5 +0,0 @@ -[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 deleted file mode 100644 index 3c9c09a39..000000000 --- a/tests/fixtures/test.srt +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index a85673a1b..000000000 --- a/tests/fixtures/test.ttml +++ /dev/null @@ -1,12 +0,0 @@ - - - -
-

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 deleted file mode 100644 index bc5022b75..000000000 --- a/tests/fixtures/test.yaml +++ /dev/null @@ -1,12 +0,0 @@ -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/harness/harness.go b/tests/harness/harness.go deleted file mode 100644 index 67f3150a2..000000000 --- a/tests/harness/harness.go +++ /dev/null @@ -1,195 +0,0 @@ -// Package harness holds the pieces shared by the API e2e suites (server/subsonic/e2e and -// server/jellyfin/e2e): golden-database lifecycle, snapshot restore, fixture-FS registration, -// and service doubles. Like core/storage/storagetest, it must only be imported from test code. -package harness - -import ( - "context" - "errors" - "io" - "os" - "path/filepath" - "strings" - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/scanner" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" //nolint:staticcheck -) - -// DB is a golden e2e database: scanned once in BeforeSuite, restored per test via Restore. -type DB struct { - FilePath string - SnapshotPath string - Library model.Library -} - -// CreateFS registers files under the "fake:" storage scheme the suites use as MusicFolder. -func CreateFS(files fstest.MapFS) storagetest.FakeFS { - fs := storagetest.FakeFS{} - fs.SetFiles(files) - storagetest.Register("fake", &fs) - return fs -} - -// SetupDB boots the golden database: a temp SQLite file, the given users (password "password", -// all with access to the seeded "Music Library"), a full scan of the registered fake FS, and a -// snapshot for per-test restore. Callers must set conf.Server.MusicFolder and register the FS -// first; each user's Libraries field is populated in place. -func SetupDB(ctx context.Context, users ...*model.User) *DB { - tmpDir := ginkgo.GinkgoT().TempDir() - h := &DB{FilePath: filepath.Join(tmpDir, "test-e2e.db")} - h.SnapshotPath = h.FilePath + ".snapshot" - conf.Server.DbPath = h.FilePath + "?_journal_mode=WAL" - db.Db().SetMaxOpenConns(1) - db.Init(ctx) - - ds := &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(ds) - - h.Library = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} - Expect(ds.Library().Put(ctx, &h.Library)).To(Succeed()) - - for _, u := range users { - seeded := *u - seeded.NewPassword = "password" - Expect(ds.User().Put(ctx, &seeded)).To(Succeed()) - Expect(ds.User().SetUserLibraries(ctx, u.ID, []int{h.Library.ID})).To(Succeed()) - loaded, err := ds.User().FindByUsername(ctx, u.UserName) - Expect(err).ToNot(HaveOccurred()) - u.Libraries = loaded.Libraries - } - - s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - _, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") - Expect(err).ToNot(HaveOccurred()) - data, err := os.ReadFile(h.FilePath) - Expect(err).ToNot(HaveOccurred()) - Expect(os.WriteFile(h.SnapshotPath, data, 0o600)).To(Succeed()) //nolint:gosec // path derives from TempDir - return h -} - -// ResettableTables lists the tables a per-spec reset may write. FTS shadow tables are excluded: -// their content tables' triggers keep them in sync, and writing them directly corrupts the index. -func ResettableTables() []string { - rows, err := db.Db().Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") - Expect(err).ToNot(HaveOccurred()) - defer rows.Close() - var tables []string - for rows.Next() { - var name string - Expect(rows.Scan(&name)).To(Succeed()) - tables = append(tables, name) - } - Expect(rows.Err()).ToNot(HaveOccurred()) - return tables -} - -// TruncateDB empties every resettable table, leaving the migrated schema in place — for suites -// whose specs each build their own library, so the schema is not re-migrated per spec. -func TruncateDB(tables []string) { - sqlDB := db.Db() - _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") - Expect(err).ToNot(HaveOccurred()) - defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }() - - for _, table := range tables { - // Table names come from sqlite_master, not user input. - _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - } -} - -// Restore reloads every table from the golden snapshot via ATTACH DATABASE — much faster than a -// rescan. -func (h *DB) Restore() { - sqlDB := db.Db() - _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", h.SnapshotPath) - Expect(err).ToNot(HaveOccurred()) - - for _, table := range ResettableTables() { - // Table names come from sqlite_master, not user input. - _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - } - - _, err = sqlDB.Exec("DETACH DATABASE snapshot") - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") - Expect(err).ToNot(HaveOccurred()) -} - -// SpyStreamer captures the Request passed to NewStream and returns a minimal fake stream. -type SpyStreamer struct { - LastRequest stream.Request - LastMediaFile *model.MediaFile - SimulateError error // when set, NewStream returns this error - SimulateEmptyStream bool // when true, returns a 0-byte stream (ffmpeg produced no output) -} - -func (s *SpyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { - s.LastRequest = req - s.LastMediaFile = mf - if s.SimulateError != nil { - return nil, s.SimulateError - } - format := req.Format - if format == "" || format == "raw" { - format = mf.Suffix - } - content := "fake audio data" - if s.SimulateEmptyStream { - content = "" - } - return stream.NewStream(mf, format, req.BitRate, io.NopCloser(strings.NewReader(content))), nil -} - -// NoopFFmpeg implements ffmpeg.FFmpeg; transcoding never actually runs in e2e. -type NoopFFmpeg struct{} - -func (NoopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: transcode not supported") -} - -func (NoopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: extract image not supported") -} - -func (NoopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { - return nil, errors.New("noop ffmpeg: probe not supported") -} - -func (NoopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: convert animated image not supported") -} - -func (NoopFFmpeg) CmdPath() (string, error) { return "", nil } -func (NoopFFmpeg) IsAvailable() bool { return false } -func (NoopFFmpeg) IsProbeAvailable() bool { return true } -func (NoopFFmpeg) Version() string { return "noop" } - -var ( - _ stream.MediaStreamer = &SpyStreamer{} - _ ffmpeg.FFmpeg = NoopFFmpeg{} -) diff --git a/tests/init_tests.go b/tests/init_tests.go index eee4428a8..582ad95fc 100644 --- a/tests/init_tests.go +++ b/tests/init_tests.go @@ -8,13 +8,12 @@ import ( "testing" "github.com/navidrome/navidrome/conf" - _ "github.com/navidrome/navidrome/conf/mime" // registers mime_types.yaml, so tests see the same image types as the server "github.com/navidrome/navidrome/log" ) var once sync.Once -func Init(t testing.TB, skipOnShort bool) { +func Init(t *testing.T, skipOnShort bool) { if skipOnShort && testing.Short() { t.Skip("skipping test in short mode.") } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index f8100c189..3428813f6 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -1,9 +1,7 @@ package tests import ( - "context" "errors" - "sync" "time" "github.com/navidrome/navidrome/model" @@ -22,8 +20,6 @@ type MockAlbumRepo struct { All model.Albums Err bool Options model.QueryOptions - optionsMu sync.Mutex - SearchQuery string // last query passed to Search ReassignAnnotationCalls map[string]string // prevID -> newID CopyAttributesCalls map[string]string // fromID -> toID } @@ -40,7 +36,7 @@ func (m *MockAlbumRepo) SetData(albums model.Albums) { } } -func (m *MockAlbumRepo) Exists(_ context.Context, id string) (bool, error) { +func (m *MockAlbumRepo) Exists(id string) (bool, error) { if m.Err { return false, errors.New("unexpected error") } @@ -48,7 +44,7 @@ func (m *MockAlbumRepo) Exists(_ context.Context, id string) (bool, error) { return found, nil } -func (m *MockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) { +func (m *MockAlbumRepo) Get(id string) (*model.Album, error) { if m.Err { return nil, errors.New("unexpected error") } @@ -58,26 +54,20 @@ func (m *MockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) return nil, model.ErrNotFound } -func (m *MockAlbumRepo) Put(_ context.Context, al *model.Album) error { +func (m *MockAlbumRepo) Put(al *model.Album) error { if m.Err { return errors.New("unexpected error") } if al.ID == "" { al.ID = id.NewRandom() } - if m.Data == nil { - m.Data = make(map[string]*model.Album) - } m.Data[al.ID] = al return nil } -func (m *MockAlbumRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.Albums, error) { +func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { if len(qo) > 0 { - // Recording the last options is a read-path write, and callers resolve concurrently. - m.optionsMu.Lock() m.Options = qo[0] - m.optionsMu.Unlock() } if m.Err { return nil, errors.New("unexpected error") @@ -85,21 +75,7 @@ func (m *MockAlbumRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (mod return m.All, nil } -func (m *MockAlbumRepo) GetCursor(ctx context.Context, qo ...model.QueryOptions) (model.AlbumCursor, error) { - res, err := m.GetAll(ctx, qo...) - if err != nil { - return nil, err - } - return func(yield func(model.Album, error) bool) { - for _, a := range res { - if !yield(a, nil) { - return - } - } - }, nil -} - -func (m *MockAlbumRepo) IncPlayCount(_ context.Context, id string, timestamp time.Time) error { +func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error { if m.Err { return errors.New("unexpected error") } @@ -110,11 +86,11 @@ func (m *MockAlbumRepo) IncPlayCount(_ context.Context, id string, timestamp tim } return model.ErrNotFound } -func (m *MockAlbumRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { +func (m *MockAlbumRepo) CountAll(...model.QueryOptions) (int64, error) { return int64(len(m.All)), nil } -func (m *MockAlbumRepo) GetTouchedAlbums(_ context.Context, libID int) (model.AlbumCursor, error) { +func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { if m.Err { return nil, errors.New("unexpected error") } @@ -136,12 +112,14 @@ func (m *MockAlbumRepo) GetTouchedAlbums(_ context.Context, libID int) (model.Al }, nil } -func (m *MockAlbumRepo) UpdateExternalInfo(ctx context.Context, album *model.Album) error { - return m.Put(ctx, album) +func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { + if m.Err { + return errors.New("unexpected error") + } + return nil } -func (m *MockAlbumRepo) Search(_ context.Context, q string, options ...model.QueryOptions) (model.Albums, error) { - m.SearchQuery = q +func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) { if len(options) > 0 { m.Options = options[0] } @@ -153,7 +131,7 @@ func (m *MockAlbumRepo) Search(_ context.Context, q string, options ...model.Que } // ReassignAnnotation reassigns annotations from one album to another -func (m *MockAlbumRepo) ReassignAnnotation(_ context.Context, prevID string, newID string) error { +func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error { if m.Err { return errors.New("unexpected error") } @@ -166,7 +144,7 @@ func (m *MockAlbumRepo) ReassignAnnotation(_ context.Context, prevID string, new } // CopyAttributes copies attributes from one album to another -func (m *MockAlbumRepo) CopyAttributes(_ context.Context, fromID, toID string, columns ...string) error { +func (m *MockAlbumRepo) CopyAttributes(fromID, toID string, columns ...string) error { if m.Err { return errors.New("unexpected error") } @@ -192,34 +170,19 @@ func (m *MockAlbumRepo) CopyAttributes(_ context.Context, fromID, toID string, c } // SetRating sets the rating for an album -func (m *MockAlbumRepo) SetRating(_ context.Context, rating int, itemID string) error { +func (m *MockAlbumRepo) SetRating(rating int, itemID string) error { if m.Err { return errors.New("unexpected error") } - if d, ok := m.Data[itemID]; ok { - d.Rating = rating - } return nil } // SetStar sets the starred status for albums -func (m *MockAlbumRepo) SetStar(_ context.Context, starred bool, itemIDs ...string) error { +func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { if m.Err { return errors.New("unexpected error") } - for _, id := range itemIDs { - if d, ok := m.Data[id]; ok { - d.Starred = starred - } - } return nil } -func (m *MockAlbumRepo) GetYears(_ context.Context, libraryIDs ...int) ([]int, error) { - if m.Err { - return nil, errors.New("error") - } - return []int{}, nil -} - var _ model.AlbumRepository = (*MockAlbumRepo)(nil) diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index fe63edd94..b7a6fb811 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -1,7 +1,6 @@ package tests import ( - "context" "errors" "time" @@ -33,7 +32,7 @@ func (m *MockArtistRepo) SetData(artists model.Artists) { } } -func (m *MockArtistRepo) Exists(_ context.Context, id string) (bool, error) { +func (m *MockArtistRepo) Exists(id string) (bool, error) { if m.Err { return false, errors.New("Error!") } @@ -41,7 +40,7 @@ func (m *MockArtistRepo) Exists(_ context.Context, id string) (bool, error) { return found, nil } -func (m *MockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) { +func (m *MockArtistRepo) Get(id string) (*model.Artist, error) { if m.Err { return nil, errors.New("Error!") } @@ -51,21 +50,18 @@ func (m *MockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error return nil, model.ErrNotFound } -func (m *MockArtistRepo) Put(_ context.Context, ar *model.Artist, columsToUpdate ...string) error { +func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error { if m.Err { return errors.New("error") } if ar.ID == "" { ar.ID = id.NewRandom() } - if m.Data == nil { - m.Data = make(map[string]*model.Artist) - } m.Data[ar.ID] = ar return nil } -func (m *MockArtistRepo) IncPlayCount(_ context.Context, id string, timestamp time.Time) error { +func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { if m.Err { return errors.New("error") } @@ -77,29 +73,7 @@ func (m *MockArtistRepo) IncPlayCount(_ context.Context, id string, timestamp ti return model.ErrNotFound } -func (m *MockArtistRepo) SetStar(_ context.Context, starred bool, itemIDs ...string) error { - if m.Err { - return errors.New("error") - } - for _, id := range itemIDs { - if d, ok := m.Data[id]; ok { - d.Starred = starred - } - } - return nil -} - -func (m *MockArtistRepo) SetRating(_ context.Context, rating int, itemID string) error { - if m.Err { - return errors.New("error") - } - if d, ok := m.Data[itemID]; ok { - d.Rating = rating - } - return nil -} - -func (m *MockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) { +func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] } @@ -117,44 +91,33 @@ func (m *MockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions return allArtists, nil } -func (m *MockArtistRepo) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.ArtistCursor, error) { - res, err := m.GetAll(ctx, options...) - if err != nil { - return nil, err +func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error { + if m.Err { + return errors.New("mock repo error") } - return func(yield func(model.Artist, error) bool) { - for _, a := range res { - if !yield(a, nil) { - return - } - } - }, nil + return nil } -func (m *MockArtistRepo) UpdateExternalInfo(ctx context.Context, artist *model.Artist) error { - return m.Put(ctx, artist) -} - -func (m *MockArtistRepo) RefreshStats(_ context.Context, allArtists bool) (int64, error) { +func (m *MockArtistRepo) RefreshStats(allArtists bool) (int64, error) { if m.Err { return 0, errors.New("mock repo error") } return int64(len(m.Data)), nil } -func (m *MockArtistRepo) RefreshPlayCounts(_ context.Context) (int64, error) { +func (m *MockArtistRepo) RefreshPlayCounts() (int64, error) { if m.Err { return 0, errors.New("mock repo error") } return int64(len(m.Data)), nil } -func (m *MockArtistRepo) GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { +func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { if m.Err { return nil, errors.New("mock repo error") } - artists, err := m.GetAll(ctx) + artists, err := m.GetAll() if err != nil { return nil, err } @@ -182,14 +145,7 @@ func (m *MockArtistRepo) GetIndex(ctx context.Context, includeMissing bool, libr return result, nil } -func (m *MockArtistRepo) CountAll(context.Context, ...model.QueryOptions) (int64, error) { - if m.Err { - return 0, errors.New("mock repo error") - } - return int64(len(m.Data)), nil -} - -func (m *MockArtistRepo) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.Artists, error) { +func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] } @@ -197,7 +153,8 @@ func (m *MockArtistRepo) Search(ctx context.Context, q string, options ...model. return nil, errors.New("unexpected error") } // Simple mock implementation - just return all artists for testing - return m.GetAll(ctx) + allArtists, err := m.GetAll() + return allArtists, err } var _ model.ArtistRepository = (*MockArtistRepo)(nil) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go deleted file mode 100644 index c6a7917f0..000000000 --- a/tests/mock_artwork_queue_repo.go +++ /dev/null @@ -1,364 +0,0 @@ -package tests - -import ( - "cmp" - "context" - "slices" - "sync" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" -) - -type MockArtworkQueueRepo struct { - model.ArtworkQueueRepository - // mu guards Data: the worker drains this mock concurrently. - mu sync.Mutex - Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType) - Err error - // ItemArtworkSource, when set, backs the set-difference insert with real item_artwork state. - ItemArtworkSource *MockArtworkRepo - // ExistingIDs is keyed by item_kind; a nil per-kind map means PurgeDangling keeps that kind. - ExistingIDs map[string]map[string]bool -} - -func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { - return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}} -} - -func (m *MockArtworkQueueRepo) Get(_ context.Context, kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - it, ok := m.Data[iaKey(kind.Prefix(), id, imageType)] - if !ok { - return nil, model.ErrNotFound - } - return &it, nil -} - -func (m *MockArtworkQueueRepo) Enqueue(_ context.Context, items ...model.ArtworkQueueItem) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - m.enqueueLocked(items) - return nil -} - -func (m *MockArtworkQueueRepo) enqueueLocked(items []model.ArtworkQueueItem) { - now := time.Now() - for _, it := range items { - if it.ImageType == "" { - it.ImageType = model.ImageTypePrimary - } - k := iaKey(it.ItemKind, it.ItemID, it.ImageType) - // Mirror the SQL: retry_at/enqueued_at are server-set, never taken from the caller. - if prev, ok := m.Data[k]; ok { - prev.Priority = max(prev.Priority, it.Priority) - prev.RetryAt = now - prev.Attempts = 0 - prev.EnqueuedAt = now - m.Data[k] = prev - continue - } - it.Attempts = 0 - it.RetryAt = now - it.EnqueuedAt = now - m.Data[k] = it - } -} - -// EnqueueIfMissing mirrors the SQL anti-join: skip anything that already has an item_artwork row. -func (m *MockArtworkQueueRepo) EnqueueIfMissing(_ context.Context, items ...model.ArtworkQueueItem) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - var fresh []model.ArtworkQueueItem - for _, it := range items { - k := iaKey(it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary)) - if m.ItemArtworkSource != nil { - if _, ok := m.ItemArtworkSource.ItemData[k]; ok { - continue - } - } - if _, ok := m.Data[k]; ok { // DO NOTHING - continue - } - fresh = append(fresh, it) - } - m.enqueueLocked(fresh) - return nil -} - -func (m *MockArtworkQueueRepo) DequeueBatch(_ context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - var res []model.ArtworkQueueItem - now := time.Now() - for _, it := range m.Data { - if !it.RetryAt.After(now) && (len(kinds) == 0 || slices.Contains(kinds, it.ItemKind)) { - res = append(res, it) - } - } - slices.SortFunc(res, func(a, b model.ArtworkQueueItem) int { - return cmp.Or(cmp.Compare(b.Priority, a.Priority), a.EnqueuedAt.Compare(b.EnqueuedAt)) - }) - if len(res) > n { - res = res[:n] - } - return res, nil -} - -func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(_ context.Context, kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - k := iaKey(kind, id, imageType) - if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) { - it.Attempts++ - it.RetryAt = retryAt - it.Trace = trace - m.Data[k] = it - } - return nil -} - -func (m *MockArtworkQueueRepo) DeleteIfUnchanged(_ context.Context, kind, id, imageType string, retryAt time.Time) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - k := iaKey(kind, id, imageType) - if it, ok := m.Data[k]; ok && it.RetryAt.Equal(retryAt) { - delete(m.Data, k) - } - return nil -} - -func (m *MockArtworkQueueRepo) PurgeDangling(context.Context) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - var purged int64 - for k, it := range m.Data { - existing := m.ExistingIDs[it.ItemKind] - if existing == nil { - continue - } - if !existing[it.ItemID] { - delete(m.Data, k) - purged++ - } - } - return purged, nil -} - -// queueFilterMatches mirrors artworkQueueFilter, so the mock cannot let a preview and a delete disagree. -func queueFilterMatches(it model.ArtworkQueueItem, kinds []model.Kind, priorities []int) bool { - prefixes := model.KindPrefixes(kinds) - return (len(prefixes) == 0 || slices.Contains(prefixes, it.ItemKind)) && - (len(priorities) == 0 || slices.Contains(priorities, it.Priority)) -} - -func (m *MockArtworkQueueRepo) PurgeQueued(_ context.Context, kinds []model.Kind, priorities []int) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - var purged int64 - for k, it := range m.Data { - if !queueFilterMatches(it, kinds, priorities) { - continue - } - delete(m.Data, k) - purged++ - } - return purged, nil -} - -func (m *MockArtworkQueueRepo) Count(context.Context) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - return int64(len(m.Data)), nil -} - -func (m *MockArtworkQueueRepo) CountQueued(_ context.Context, kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - var res []model.ArtworkQueueStat - for _, it := range m.Data { - if !queueFilterMatches(it, kinds, priorities) { - continue - } - i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool { - return s.ItemKind == it.ItemKind && s.Priority == it.Priority - }) - if i < 0 { - res = append(res, model.ArtworkQueueStat{ItemKind: it.ItemKind, Priority: it.Priority, Count: 1}) - continue - } - res[i].Count++ - } - slices.SortFunc(res, func(a, b model.ArtworkQueueStat) int { - return cmp.Or(cmp.Compare(a.ItemKind, b.ItemKind), cmp.Compare(b.Priority, a.Priority)) - }) - return res, nil -} - -func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(_ context.Context, items ...model.ArtworkQueueItem) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - now := time.Now() - for _, it := range items { - if it.ImageType == "" { - it.ImageType = model.ImageTypePrimary - } - k := iaKey(it.ItemKind, it.ItemID, it.ImageType) - // Preserve an existing row's retry_at: a bump raises priority without resetting backoff. - if prev, ok := m.Data[k]; ok { - prev.Priority = max(prev.Priority, it.Priority) - m.Data[k] = prev - continue - } - it.Attempts = 0 - it.RetryAt = now - it.EnqueuedAt = now - m.Data[k] = it - } - return nil -} - -// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state, and -// ArtworkSourceFailed the absent states that gave up. -func (m *MockArtworkQueueRepo) matchingSource(kind model.Kind, sources []string) []model.ItemArtwork { - if m.ItemArtworkSource == nil { - return nil - } - matches := func(ia model.ItemArtwork) bool { - if len(sources) == 0 { - return true - } - if slices.Contains(sources, ia.Source) { - return true - } - return slices.Contains(sources, model.ArtworkSourceFailed) && ia.Hash == "" && ia.LastFailure != "" - } - var res []model.ItemArtwork - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind == kind.Prefix() && matches(ia) { - res = append(res, ia) - } - } - return res -} - -func (m *MockArtworkQueueRepo) CountBySource(_ context.Context, kind model.Kind, sources []string) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - return int64(len(m.matchingSource(kind, sources))), nil -} - -func (m *MockArtworkQueueRepo) SourcesInUse(_ context.Context, kind model.Kind) ([]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - sources := slice.Map(m.matchingSource(kind, nil), func(ia model.ItemArtwork) string { return ia.Source }) - return slice.Unique(sources), nil -} - -func (m *MockArtworkQueueRepo) EnqueueBySource(_ context.Context, kind model.Kind, sources []string, priority int) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - now := time.Now() - var inserted int64 - for _, ia := range m.matchingSource(kind, sources) { - k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) - if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows - continue - } - m.Data[k] = model.ArtworkQueueItem{ - ItemKind: ia.ItemKind, - ItemID: ia.ItemID, - ImageType: ia.ImageType, - Priority: priority, - RetryAt: now, - EnqueuedAt: now, - } - inserted++ - } - return inserted, nil -} - -// EnqueueAllMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. -func (m *MockArtworkQueueRepo) EnqueueAllMissing(_ context.Context, kind model.Kind, priority int) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - hasRow := func(id string) bool { - if m.ItemArtworkSource == nil { - return false - } - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind == kind.Prefix() && ia.ItemID == id { - return true - } - } - return false - } - now := time.Now() - var inserted int64 - for id := range m.ExistingIDs[kind.Prefix()] { - if hasRow(id) { - continue - } - k := iaKey(kind.Prefix(), id, model.ImageTypePrimary) - if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows - continue - } - m.Data[k] = model.ArtworkQueueItem{ - ItemKind: kind.Prefix(), - ItemID: id, - ImageType: model.ImageTypePrimary, - Priority: priority, - RetryAt: now, - EnqueuedAt: now, - } - inserted++ - } - return inserted, nil -} diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go deleted file mode 100644 index 350c0e9f6..000000000 --- a/tests/mock_artwork_repo.go +++ /dev/null @@ -1,190 +0,0 @@ -package tests - -import ( - "context" - "maps" - "sync" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" -) - -type MockArtworkRepo struct { - model.ArtworkRepository - // mu guards the maps so the worker's concurrent drain can hit this mock race-free. - mu sync.Mutex - Data map[string]model.Artwork - ItemData map[string]model.ItemArtwork // keyed by iaKey(kind, id, imageType) - Err error - // ExistingIDs, keyed by item_kind, backs PurgeDanglingItems; nil map keeps everything. - ExistingIDs map[string]map[string]bool -} - -func CreateMockArtworkRepo() *MockArtworkRepo { - return &MockArtworkRepo{Data: map[string]model.Artwork{}, ItemData: map[string]model.ItemArtwork{}} -} - -func iaKey(kind, id, imageType string) string { return kind + "|" + id + "|" + imageType } - -func (m *MockArtworkRepo) GetImage(_ context.Context, hash string) (*model.Artwork, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - if a, ok := m.Data[hash]; ok { - return &a, nil - } - return nil, model.ErrNotFound -} - -func (m *MockArtworkRepo) PutImage(_ context.Context, a *model.Artwork) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - // Mirrors the SQL repository: every upsert refreshes created_at. Age fixtures via Data directly. - a.CreatedAt = time.Now() - m.Data[a.Hash] = *a - return nil -} - -func (m *MockArtworkRepo) GetMimeByHash(context.Context) (map[string]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - mimes := make(map[string]string, len(m.Data)) - for h, a := range m.Data { - mimes[h] = a.Mime - } - return mimes, nil -} - -func (m *MockArtworkRepo) PurgeDanglingItems(context.Context) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - var purged int64 - for k, ia := range m.ItemData { - // A nil per-kind map means that kind isn't tracked by the test, so keep it. - existing := m.ExistingIDs[ia.ItemKind] - if existing == nil { - continue - } - if !existing[ia.ItemID] { - delete(m.ItemData, k) - purged++ - } - } - return purged, nil -} - -func (m *MockArtworkRepo) PurgeOrphans(_ context.Context, createdBefore time.Time) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - var deleted int64 - for h, a := range m.Data { - if m.referenced(h) || !a.CreatedAt.Before(createdBefore) { - continue - } - delete(m.Data, h) - deleted++ - } - return deleted, nil -} - -func (m *MockArtworkRepo) referenced(hash string) bool { - for _, ia := range m.ItemData { - if ia.Hash == hash { - return true - } - } - return false -} - -func (m *MockArtworkRepo) GetItemArtwork(_ context.Context, kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - if ia, ok := m.ItemData[iaKey(kind.Prefix(), id, imageType)]; ok { - return &ia, nil - } - return nil, model.ErrNotFound -} - -func (m *MockArtworkRepo) PutLastFailure(_ context.Context, kind model.Kind, id, imageType, trace string) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - key := iaKey(kind.Prefix(), id, imageType) - if ia, ok := m.ItemData[key]; ok { - ia.LastFailure = trace - m.ItemData[key] = ia - } - return nil -} - -func (m *MockArtworkRepo) PutItemArtwork(_ context.Context, ia *model.ItemArtwork) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - if ia.ImageType == "" { - ia.ImageType = model.ImageTypePrimary - } - ia.UpdatedAt = time.Now() - if ia.AttemptedAt.IsZero() { - ia.AttemptedAt = ia.UpdatedAt - } - m.ItemData[iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)] = *ia - return nil -} - -func (m *MockArtworkRepo) DeleteForItems(_ context.Context, kind model.Kind, ids []string) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - idSet := slice.ToSet(ids) - maps.DeleteFunc(m.ItemData, func(_ string, ia model.ItemArtwork) bool { - _, ok := idSet[ia.ItemID] - return ok && ia.ItemKind == kind.Prefix() - }) - return nil -} - -func (m *MockArtworkRepo) GetInfoForItems(_ context.Context, kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - res := map[string]model.ItemArtworkInfo{} - for _, id := range ids { - if ia, ok := m.ItemData[iaKey(kind.Prefix(), id, model.ImageTypePrimary)]; ok { - info := model.ItemArtworkInfo{ItemID: id, Hash: ia.Hash} - if a, ok := m.Data[ia.Hash]; ok { - info.BlurHash, info.ThumbHash = a.BlurHash, a.ThumbHash - info.DominantColor = a.DominantColor - info.Width, info.Height = a.Width, a.Height - } - res[id] = info - } - } - return res, nil -} diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index a5e4126cf..754f0c084 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -28,8 +28,6 @@ type MockDataStore struct { MockedScrobble model.ScrobbleRepository MockedRadio model.RadioRepository MockedPlugin model.PluginRepository - MockedArtwork model.ArtworkRepository - MockedArtworkQueue model.ArtworkQueueRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -38,205 +36,175 @@ type MockDataStore struct { GCError error } -func (db *MockDataStore) Library() model.LibraryRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { if db.MockedLibrary != nil { return db.MockedLibrary } if db.RealDS != nil { - return db.RealDS.Library() + return db.RealDS.Library(ctx) } db.MockedLibrary = &MockLibraryRepo{} return db.MockedLibrary } -func (db *MockDataStore) Folder() model.FolderRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository { if db.MockedFolder != nil { return db.MockedFolder } if db.RealDS != nil { - return db.RealDS.Folder() + return db.RealDS.Folder(ctx) } db.MockedFolder = struct{ model.FolderRepository }{} return db.MockedFolder } -func (db *MockDataStore) Tag() model.TagRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { if db.MockedTag != nil { return db.MockedTag } if db.RealDS != nil { - return db.RealDS.Tag() + return db.RealDS.Tag(ctx) } - db.MockedTag = &MockTagRepo{} + db.MockedTag = struct{ model.TagRepository }{} return db.MockedTag } -func (db *MockDataStore) Album() model.AlbumRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Album(ctx context.Context) model.AlbumRepository { if db.MockedAlbum != nil { return db.MockedAlbum } if db.RealDS != nil { - return db.RealDS.Album() + return db.RealDS.Album(ctx) } db.MockedAlbum = CreateMockAlbumRepo() return db.MockedAlbum } -func (db *MockDataStore) Artist() model.ArtistRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository { if db.MockedArtist != nil { return db.MockedArtist } if db.RealDS != nil { - return db.RealDS.Artist() + return db.RealDS.Artist(ctx) } db.MockedArtist = CreateMockArtistRepo() return db.MockedArtist } -func (db *MockDataStore) MediaFile() model.MediaFileRepository { +func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + if db.RealDS != nil && db.MockedMediaFile == nil { + return db.RealDS.MediaFile(ctx) + } db.repoMu.Lock() defer db.repoMu.Unlock() - if db.RealDS != nil && db.MockedMediaFile == nil { - return db.RealDS.MediaFile() - } if db.MockedMediaFile == nil { db.MockedMediaFile = CreateMockMediaFileRepo() } return db.MockedMediaFile } -func (db *MockDataStore) Genre() model.GenreRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Genre(ctx context.Context) model.GenreRepository { if db.MockedGenre != nil { return db.MockedGenre } if db.RealDS != nil { - return db.RealDS.Genre() + return db.RealDS.Genre(ctx) } db.MockedGenre = &MockedGenreRepo{} return db.MockedGenre } -func (db *MockDataStore) Playlist() model.PlaylistRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository { if db.MockedPlaylist != nil { return db.MockedPlaylist } if db.RealDS != nil { - return db.RealDS.Playlist() + return db.RealDS.Playlist(ctx) } db.MockedPlaylist = CreateMockPlaylistRepo() return db.MockedPlaylist } -func (db *MockDataStore) PlayQueue() model.PlayQueueRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { if db.MockedPlayQueue != nil { return db.MockedPlayQueue } if db.RealDS != nil { - return db.RealDS.PlayQueue() + return db.RealDS.PlayQueue(ctx) } db.MockedPlayQueue = &MockPlayQueueRepo{} return db.MockedPlayQueue } -func (db *MockDataStore) UserProps() model.UserPropsRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepository { if db.MockedUserProps != nil { return db.MockedUserProps } if db.RealDS != nil { - return db.RealDS.UserProps() + return db.RealDS.UserProps(ctx) } db.MockedUserProps = &MockedUserPropsRepo{} return db.MockedUserProps } -func (db *MockDataStore) Property() model.PropertyRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Property(ctx context.Context) model.PropertyRepository { if db.MockedProperty != nil { return db.MockedProperty } if db.RealDS != nil { - return db.RealDS.Property() + return db.RealDS.Property(ctx) } db.MockedProperty = &MockedPropertyRepo{} return db.MockedProperty } -func (db *MockDataStore) Share() model.ShareRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Share(ctx context.Context) model.ShareRepository { if db.MockedShare != nil { return db.MockedShare } if db.RealDS != nil { - return db.RealDS.Share() + return db.RealDS.Share(ctx) } db.MockedShare = &MockShareRepo{} return db.MockedShare } -func (db *MockDataStore) User() model.UserRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) User(ctx context.Context) model.UserRepository { if db.MockedUser != nil { return db.MockedUser } if db.RealDS != nil { - return db.RealDS.User() + return db.RealDS.User(ctx) } db.MockedUser = CreateMockUserRepo() return db.MockedUser } -func (db *MockDataStore) Transcoding() model.TranscodingRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Transcoding(ctx context.Context) model.TranscodingRepository { if db.MockedTranscoding != nil { return db.MockedTranscoding } if db.RealDS != nil { - return db.RealDS.Transcoding() + return db.RealDS.Transcoding(ctx) } db.MockedTranscoding = struct{ model.TranscodingRepository }{} return db.MockedTranscoding } -func (db *MockDataStore) Player() model.PlayerRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository { if db.MockedPlayer != nil { return db.MockedPlayer } if db.RealDS != nil { - return db.RealDS.Player() + return db.RealDS.Player(ctx) } - db.MockedPlayer = CreateMockPlayerRepo() + db.MockedPlayer = struct{ model.PlayerRepository }{} return db.MockedPlayer } -func (db *MockDataStore) ScrobbleBuffer() model.ScrobbleBufferRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { if db.RealDS != nil && db.MockedScrobbleBuffer == nil { - return db.RealDS.ScrobbleBuffer() + return db.RealDS.ScrobbleBuffer(ctx) } db.scrobbleBufferMu.Lock() defer db.scrobbleBufferMu.Unlock() @@ -246,81 +214,39 @@ func (db *MockDataStore) ScrobbleBuffer() model.ScrobbleBufferRepository { return db.MockedScrobbleBuffer } -func (db *MockDataStore) Scrobble() model.ScrobbleRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository { if db.MockedScrobble != nil { return db.MockedScrobble } if db.RealDS != nil { - return db.RealDS.Scrobble() + return db.RealDS.Scrobble(ctx) } - db.MockedScrobble = &MockScrobbleRepo{} + db.MockedScrobble = &MockScrobbleRepo{ctx: ctx} return db.MockedScrobble } -func (db *MockDataStore) Radio() model.RadioRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { if db.MockedRadio != nil { return db.MockedRadio } if db.RealDS != nil { - return db.RealDS.Radio() + return db.RealDS.Radio(ctx) } db.MockedRadio = CreateMockedRadioRepo() return db.MockedRadio } -func (db *MockDataStore) Plugin() model.PluginRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() +func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { if db.MockedPlugin != nil { return db.MockedPlugin } if db.RealDS != nil { - return db.RealDS.Plugin() + return db.RealDS.Plugin(ctx) } db.MockedPlugin = CreateMockPluginRepo() return db.MockedPlugin } -func (db *MockDataStore) Artwork() model.ArtworkRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - return db.artworkLocked() -} - -// artworkLocked is the body of Artwork for callers already holding repoMu; repoMu is a plain -// Mutex, so re-entering through the exported method would deadlock. -func (db *MockDataStore) artworkLocked() model.ArtworkRepository { - if db.MockedArtwork != nil { - return db.MockedArtwork - } - if db.RealDS != nil { - return db.RealDS.Artwork() - } - db.MockedArtwork = CreateMockArtworkRepo() - return db.MockedArtwork -} - -func (db *MockDataStore) ArtworkQueue() model.ArtworkQueueRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedArtworkQueue != nil { - return db.MockedArtworkQueue - } - if db.RealDS != nil { - return db.RealDS.ArtworkQueue() - } - q := CreateMockArtworkQueueRepo() - if aw, ok := db.artworkLocked().(*MockArtworkRepo); ok { - q.ItemArtworkSource = aw - } - db.MockedArtworkQueue = q - return db.MockedArtworkQueue -} - func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } @@ -329,8 +255,35 @@ func (db *MockDataStore) WithTxImmediate(block func(tx model.DataStore) error, l return block(db) } -func (db *MockDataStore) WithTxRetry(ctx context.Context, block func(ctx context.Context, tx model.DataStore) error, label ...string) error { - return block(ctx, db) +func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepository { + switch m.(type) { + case model.MediaFile, *model.MediaFile: + return db.MediaFile(ctx).(model.ResourceRepository) + case model.Album, *model.Album: + return db.Album(ctx).(model.ResourceRepository) + case model.Artist, *model.Artist: + return db.Artist(ctx).(model.ResourceRepository) + case model.User, *model.User: + return db.User(ctx).(model.ResourceRepository) + case model.Playlist, *model.Playlist: + return db.Playlist(ctx).(model.ResourceRepository) + case model.Radio, *model.Radio: + return db.Radio(ctx).(model.ResourceRepository) + case model.Share, *model.Share: + return db.Share(ctx).(model.ResourceRepository) + case model.Genre, *model.Genre: + return db.Genre(ctx).(model.ResourceRepository) + case model.Tag, *model.Tag: + return db.Tag(ctx).(model.ResourceRepository) + case model.Transcoding, *model.Transcoding: + return db.Transcoding(ctx).(model.ResourceRepository) + case model.Player, *model.Player: + return db.Player(ctx).(model.ResourceRepository) + case model.Plugin, *model.Plugin: + return db.Plugin(ctx).(model.ResourceRepository) + default: + return struct{ model.ResourceRepository }{} + } } func (db *MockDataStore) GC(context.Context, ...int) error { diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go index 8e4d12f0b..f9862767e 100644 --- a/tests/mock_ffmpeg.go +++ b/tests/mock_ffmpeg.go @@ -57,6 +57,12 @@ func (ff *MockFFmpeg) ConvertAnimatedImage(_ context.Context, reader io.Reader, return io.NopCloser(bytes.NewReader(data)), nil } +func (ff *MockFFmpeg) Probe(context.Context, []string) (string, error) { + if ff.Error != nil { + return "", ff.Error + } + return "", nil +} func (ff *MockFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { if ff.Error != nil { return nil, ff.Error diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index 917e2c626..122ccc278 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -1,16 +1,12 @@ package tests import ( - "context" - - "github.com/deluan/rest" "github.com/navidrome/navidrome/model" ) type MockedGenreRepo struct { - Error error - Data map[string]model.Genre - Options model.QueryOptions + Error error + Data map[string]model.Genre } func (r *MockedGenreRepo) init() { @@ -19,10 +15,7 @@ func (r *MockedGenreRepo) init() { } } -func (r *MockedGenreRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Genres, error) { - if len(options) > 0 { - r.Options = options[0] - } +func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) { if r.Error != nil { return nil, r.Error } @@ -35,17 +28,6 @@ func (r *MockedGenreRepo) GetAll(_ context.Context, options ...model.QueryOption return all, nil } -func (r *MockedGenreRepo) Get(_ context.Context, id string) (*model.Genre, error) { - if r.Error != nil { - return nil, r.Error - } - r.init() - if g, ok := r.Data[id]; ok { - return &g, nil - } - return nil, model.ErrNotFound -} - func (r *MockedGenreRepo) Put(g *model.Genre) error { if r.Error != nil { return r.Error @@ -54,21 +36,3 @@ func (r *MockedGenreRepo) Put(g *model.Genre) error { r.Data[g.ID] = *g return nil } - -func (r *MockedGenreRepo) Count(context.Context, ...rest.QueryOptions) (int64, error) { - if r.Error != nil { - return 0, r.Error - } - r.init() - return int64(len(r.Data)), nil -} - -func (r *MockedGenreRepo) Read(ctx context.Context, id string) (*model.Genre, error) { - return r.Get(ctx, id) -} - -func (r *MockedGenreRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Genre, error) { - return r.GetAll(ctx) -} - -var _ model.GenreRepository = (*MockedGenreRepo)(nil) diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 6de2b9265..3f0e576e9 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -14,10 +14,9 @@ import ( type MockLibraryRepo struct { model.LibraryRepository - Data map[int]model.Library - Err error - PutFn func(*model.Library) error // Allow custom Put behavior for testing - PutCols []string + Data map[int]model.Library + Err error + PutFn func(*model.Library) error // Allow custom Put behavior for testing } func (m *MockLibraryRepo) SetData(data model.Libraries) { @@ -27,7 +26,7 @@ func (m *MockLibraryRepo) SetData(data model.Libraries) { } } -func (m *MockLibraryRepo) GetAll(_ context.Context, _ ...model.QueryOptions) (model.Libraries, error) { +func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error) { if m.Err != nil { return nil, m.Err } @@ -42,7 +41,7 @@ func (m *MockLibraryRepo) GetAll(_ context.Context, _ ...model.QueryOptions) (mo return libraries, nil } -func (m *MockLibraryRepo) CountAll(_ context.Context, qo ...model.QueryOptions) (int64, error) { +func (m *MockLibraryRepo) CountAll(qo ...model.QueryOptions) (int64, error) { if m.Err != nil { return 0, m.Err } @@ -71,7 +70,7 @@ func (m *MockLibraryRepo) CountAll(_ context.Context, qo ...model.QueryOptions) return int64(len(m.Data)), nil } -func (m *MockLibraryRepo) Get(_ context.Context, id int) (*model.Library, error) { +func (m *MockLibraryRepo) Get(id int) (*model.Library, error) { if m.Err != nil { return nil, m.Err } @@ -81,7 +80,7 @@ func (m *MockLibraryRepo) Get(_ context.Context, id int) (*model.Library, error) return nil, model.ErrNotFound } -func (m *MockLibraryRepo) GetPath(_ context.Context, id int) (string, error) { +func (m *MockLibraryRepo) GetPath(id int) (string, error) { if m.Err != nil { return "", m.Err } @@ -91,8 +90,7 @@ func (m *MockLibraryRepo) GetPath(_ context.Context, id int) (string, error) { return "", model.ErrNotFound } -func (m *MockLibraryRepo) Put(_ context.Context, library *model.Library, colsToUpdate ...string) error { - m.PutCols = colsToUpdate +func (m *MockLibraryRepo) Put(library *model.Library) error { if m.PutFn != nil { return m.PutFn(library) } @@ -106,7 +104,7 @@ func (m *MockLibraryRepo) Put(_ context.Context, library *model.Library, colsToU return nil } -func (m *MockLibraryRepo) Delete(_ context.Context, id int) error { +func (m *MockLibraryRepo) Delete(id int) error { if m.Err != nil { return m.Err } @@ -117,59 +115,48 @@ func (m *MockLibraryRepo) Delete(_ context.Context, id int) error { return nil } -func (m *MockLibraryRepo) StoreMusicFolder(_ context.Context) error { +func (m *MockLibraryRepo) StoreMusicFolder() error { if m.Err != nil { return m.Err } return nil } -func (m *MockLibraryRepo) AddArtist(_ context.Context, id int, artistID string) error { +func (m *MockLibraryRepo) AddArtist(id int, artistID string) error { if m.Err != nil { return m.Err } return nil } -func (m *MockLibraryRepo) ScanBegin(_ context.Context, id int, fullScan bool) error { +func (m *MockLibraryRepo) ScanBegin(id int, fullScan bool) error { if m.Err != nil { return m.Err } return nil } -func (m *MockLibraryRepo) ScanEnd(_ context.Context, id int) error { +func (m *MockLibraryRepo) ScanEnd(id int) error { if m.Err != nil { return m.Err } return nil } -func (m *MockLibraryRepo) SetScannedPID(_ context.Context, id int, pid model.PIDConfig) error { - if m.Err != nil { - return m.Err - } - if lib, ok := m.Data[id]; ok { - lib.ScannedPIDAlbum, lib.ScannedPIDTrack = pid.Album, pid.Track - m.Data[id] = lib - } - return nil -} - -func (m *MockLibraryRepo) ScanInProgress(_ context.Context) (bool, error) { +func (m *MockLibraryRepo) ScanInProgress() (bool, error) { if m.Err != nil { return false, m.Err } return false, nil } -func (m *MockLibraryRepo) RefreshStats(_ context.Context, id int) error { +func (m *MockLibraryRepo) RefreshStats(id int) error { return nil } // User-library association methods - mock implementations -func (m *MockLibraryRepo) GetUsersWithLibraryAccess(_ context.Context, libraryID int) (model.Users, error) { +func (m *MockLibraryRepo) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) { if m.Err != nil { return nil, m.Err } @@ -177,22 +164,35 @@ func (m *MockLibraryRepo) GetUsersWithLibraryAccess(_ context.Context, libraryID return model.Users{}, nil } -func (m *MockLibraryRepo) Count(ctx context.Context, _ ...rest.QueryOptions) (int64, error) { - return m.CountAll(ctx) +func (m *MockLibraryRepo) Count(options ...rest.QueryOptions) (int64, error) { + return m.CountAll() } -func (m *MockLibraryRepo) Read(ctx context.Context, id string) (*model.Library, error) { +func (m *MockLibraryRepo) Read(id string) (any, error) { idInt, _ := strconv.Atoi(id) - return m.Get(ctx, idInt) + mf, err := m.Get(idInt) + if errors.Is(err, model.ErrNotFound) { + return nil, rest.ErrNotFound + } + return mf, err } -func (m *MockLibraryRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Library, error) { - return m.GetAll(ctx) +func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (any, error) { + return m.GetAll() +} + +func (m *MockLibraryRepo) EntityName() string { + return "library" +} + +func (m *MockLibraryRepo) NewInstance() any { + return &model.Library{} } // REST Repository methods (string-based IDs) -func (m *MockLibraryRepo) Save(_ context.Context, lib *model.Library) (string, error) { +func (m *MockLibraryRepo) Save(entity any) (string, error) { + lib := entity.(*model.Library) if m.Err != nil { return "", m.Err } @@ -216,8 +216,8 @@ func (m *MockLibraryRepo) Save(_ context.Context, lib *model.Library) (string, e return strconv.Itoa(lib.ID), nil } -func (m *MockLibraryRepo) Update(_ context.Context, id string, entity model.Library, _ ...string) error { - lib := &entity +func (m *MockLibraryRepo) Update(id string, entity any, cols ...string) error { + lib := entity.(*model.Library) if m.Err != nil { return m.Err } @@ -309,4 +309,4 @@ func (m *MockLibraryRepo) ValidateLibraryAccess(ctx context.Context, userID stri } var _ model.LibraryRepository = (*MockLibraryRepo)(nil) -var _ rest.Repository[model.Library] = (*MockLibraryRepo)(nil) +var _ model.ResourceRepository = (*MockLibraryRepo)(nil) diff --git a/tests/mock_library_service.go b/tests/mock_library_service.go index f5e1f0387..78693197d 100644 --- a/tests/mock_library_service.go +++ b/tests/mock_library_service.go @@ -14,7 +14,7 @@ type MockLibraryService struct { *MockLibraryRepo } -// MockLibraryRestAdapter adapts MockLibraryRepo to the REST repository interface +// MockLibraryRestAdapter adapts MockLibraryRepo to rest.Repository interface type MockLibraryRestAdapter struct { *MockLibraryRepo } @@ -33,15 +33,12 @@ func NewMockLibraryService() *MockLibraryService { return &MockLibraryService{MockLibraryRepo: repo} } -func (m *MockLibraryService) Repository() rest.Repository[model.Library] { +func (m *MockLibraryService) NewRepository(ctx context.Context) rest.Repository { return &MockLibraryRestAdapter{MockLibraryRepo: m.MockLibraryRepo} } -func (a *MockLibraryRestAdapter) Delete(_ context.Context, ids ...string) error { - for _, id := range ids { - if err := a.DeleteByStringID(id); err != nil { - return err - } - } - return nil +// rest.Repository interface implementation + +func (a *MockLibraryRestAdapter) Delete(id string) error { + return a.DeleteByStringID(id) } diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 392a4d8e0..01eacae30 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -2,7 +2,6 @@ package tests import ( "cmp" - "context" "errors" "maps" "slices" @@ -10,7 +9,6 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" ) @@ -25,30 +23,20 @@ type MockMediaFileRepo struct { model.MediaFileRepository Data map[string]*model.MediaFile Err bool - // Add fields and methods for controlling CountAll and DeleteAllMissing in tests. - // A nil CountAllValue is unset, and CountAll falls back to counting rows in Data. - CountAllValue *int64 + // Add fields and methods for controlling CountAll and DeleteAllMissing in tests + CountAllValue int64 CountAllOptions model.QueryOptions DeleteAllMissingValue int64 - // ReassignReferencesCalls records prevID -> newID - ReassignReferencesCalls map[string]string - Options model.QueryOptions + Options model.QueryOptions // Add fields for cross-library move detection tests FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) - MatchesCriteriaValue bool - MatchesCriteriaErr error - BookmarksAdded []string } func (m *MockMediaFileRepo) SetError(err bool) { m.Err = err } -func (m *MockMediaFileRepo) SetCountAll(count int64) { - m.CountAllValue = &count -} - func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) { m.Data = make(map[string]*model.MediaFile) for i, mf := range mfs { @@ -56,7 +44,7 @@ func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) { } } -func (m *MockMediaFileRepo) Exists(_ context.Context, id string) (bool, error) { +func (m *MockMediaFileRepo) Exists(id string) (bool, error) { if m.Err { return false, errors.New("error") } @@ -64,7 +52,7 @@ func (m *MockMediaFileRepo) Exists(_ context.Context, id string) (bool, error) { return found, nil } -func (m *MockMediaFileRepo) Get(_ context.Context, id string) (*model.MediaFile, error) { +func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) { if m.Err { return nil, errors.New("error") } @@ -78,15 +66,7 @@ func (m *MockMediaFileRepo) Get(_ context.Context, id string) (*model.MediaFile, return nil, model.ErrNotFound } -func (m *MockMediaFileRepo) AddBookmark(_ context.Context, id, _ string, _ int64) error { - if m.Err { - return errors.New("error") - } - m.BookmarksAdded = append(m.BookmarksAdded, id) - return nil -} - -func (m *MockMediaFileRepo) GetWithParticipants(_ context.Context, id string) (*model.MediaFile, error) { +func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) { if m.Err { return nil, errors.New("error") } @@ -96,11 +76,11 @@ func (m *MockMediaFileRepo) GetWithParticipants(_ context.Context, id string) (* return nil, model.ErrNotFound } -func (m *MockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { - return m.GetAll(ctx, options...) +func (m *MockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { + return m.GetAll(options...) } -func (m *MockMediaFileRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.MediaFiles, error) { +func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, error) { if len(qo) > 0 { m.Options = qo[0] } @@ -118,36 +98,7 @@ func (m *MockMediaFileRepo) GetAll(_ context.Context, qo ...model.QueryOptions) return result, nil } -func (m *MockMediaFileRepo) GetRandom(ctx context.Context, qo ...model.QueryOptions) (model.MediaFiles, error) { - res, err := m.GetAll(ctx, 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) GetCursor(ctx context.Context, qo ...model.QueryOptions) (model.MediaFileCursor, error) { - res, err := m.GetAll(ctx, qo...) - if err != nil { - return nil, err - } - return func(yield func(model.MediaFile, error) bool) { - for _, mf := range res { - if !yield(mf, nil) { - return - } - } - }, nil -} - -func (m *MockMediaFileRepo) GetCursorWithArtwork(ctx context.Context, qo ...model.QueryOptions) (model.MediaFileCursor, error) { - return m.GetCursor(ctx, qo...) -} - -func (m *MockMediaFileRepo) Put(_ context.Context, mf *model.MediaFile) error { +func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") } @@ -158,7 +109,7 @@ func (m *MockMediaFileRepo) Put(_ context.Context, mf *model.MediaFile) error { return nil } -func (m *MockMediaFileRepo) UpdateProbeData(_ context.Context, id string, data string) error { +func (m *MockMediaFileRepo) UpdateProbeData(id string, data string) error { if m.Err { return errors.New("error") } @@ -169,7 +120,7 @@ func (m *MockMediaFileRepo) UpdateProbeData(_ context.Context, id string, data s return model.ErrNotFound } -func (m *MockMediaFileRepo) Delete(_ context.Context, id string) error { +func (m *MockMediaFileRepo) Delete(id string) error { if m.Err { return errors.New("error") } @@ -180,18 +131,7 @@ func (m *MockMediaFileRepo) Delete(_ context.Context, id string) error { return nil } -func (m *MockMediaFileRepo) ReassignReferences(_ context.Context, prevID, newID string) error { - if m.Err { - return errors.New("error") - } - if m.ReassignReferencesCalls == nil { - m.ReassignReferencesCalls = make(map[string]string) - } - m.ReassignReferencesCalls[prevID] = newID - return nil -} - -func (m *MockMediaFileRepo) IncPlayCount(_ context.Context, id string, timestamp time.Time) error { +func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { if m.Err { return errors.New("error") } @@ -203,28 +143,6 @@ func (m *MockMediaFileRepo) IncPlayCount(_ context.Context, id string, timestamp return model.ErrNotFound } -func (m *MockMediaFileRepo) SetStar(_ context.Context, starred bool, itemIDs ...string) error { - if m.Err { - return errors.New("error") - } - for _, id := range itemIDs { - if d, ok := m.Data[id]; ok { - d.Starred = starred - } - } - return nil -} - -func (m *MockMediaFileRepo) SetRating(_ context.Context, rating int, itemID string) error { - if m.Err { - return errors.New("error") - } - if d, ok := m.Data[itemID]; ok { - d.Rating = rating - } - return nil -} - func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) { if m.Err { return nil, errors.New("error") @@ -241,7 +159,7 @@ func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, erro return res, nil } -func (m *MockMediaFileRepo) GetMissingAndMatching(_ context.Context, libId int) (model.MediaFileCursor, error) { +func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) { if m.Err { return nil, errors.New("error") } @@ -275,20 +193,20 @@ func (m *MockMediaFileRepo) GetMissingAndMatching(_ context.Context, libId int) }, nil } -func (m *MockMediaFileRepo) CountAll(_ context.Context, opts ...model.QueryOptions) (int64, error) { +func (m *MockMediaFileRepo) CountAll(opts ...model.QueryOptions) (int64, error) { if m.Err { return 0, errors.New("error") } - if len(opts) > 0 { - m.CountAllOptions = opts[0] - } - if m.CountAllValue != nil { - return *m.CountAllValue, nil + if m.CountAllValue != 0 { + if len(opts) > 0 { + m.CountAllOptions = opts[0] + } + return m.CountAllValue, nil } return int64(len(m.Data)), nil } -func (m *MockMediaFileRepo) DeleteAllMissing(_ context.Context) (int64, error) { +func (m *MockMediaFileRepo) DeleteAllMissing() (int64, error) { if m.Err { return 0, errors.New("error") } @@ -306,20 +224,32 @@ func (m *MockMediaFileRepo) DeleteAllMissing(_ context.Context) (int64, error) { return count, nil } -// REST repository methods -func (m *MockMediaFileRepo) Count(ctx context.Context, _ ...rest.QueryOptions) (int64, error) { - return m.CountAll(ctx) +// ResourceRepository methods +func (m *MockMediaFileRepo) Count(...rest.QueryOptions) (int64, error) { + return m.CountAll() } -func (m *MockMediaFileRepo) Read(ctx context.Context, id string) (*model.MediaFile, error) { - return m.Get(ctx, id) +func (m *MockMediaFileRepo) Read(id string) (any, error) { + mf, err := m.Get(id) + if errors.Is(err, model.ErrNotFound) { + return nil, rest.ErrNotFound + } + return mf, err } -func (m *MockMediaFileRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.MediaFile, error) { - return m.GetAll(ctx) +func (m *MockMediaFileRepo) ReadAll(...rest.QueryOptions) (any, error) { + return m.GetAll() } -func (m *MockMediaFileRepo) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *MockMediaFileRepo) EntityName() string { + return "mediafile" +} + +func (m *MockMediaFileRepo) NewInstance() any { + return &model.MediaFile{} +} + +func (m *MockMediaFileRepo) Search(q string, options ...model.QueryOptions) (model.MediaFiles, error) { if len(options) > 0 { m.Options = options[0] } @@ -327,11 +257,12 @@ func (m *MockMediaFileRepo) Search(ctx context.Context, q string, options ...mod return nil, errors.New("unexpected error") } // Simple mock implementation - just return all media files for testing - return m.GetAll(ctx) + allFiles, err := m.GetAll() + return allFiles, err } // Cross-library move detection mock methods -func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(_ context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { +func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { if m.Err { return nil, errors.New("error") } @@ -353,7 +284,7 @@ func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(_ context.Context, missi return result, nil } -func (m *MockMediaFileRepo) FindRecentFilesByProperties(_ context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { +func (m *MockMediaFileRepo) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { if m.Err { return nil, errors.New("error") } @@ -379,12 +310,5 @@ func (m *MockMediaFileRepo) FindRecentFilesByProperties(_ context.Context, missi return result, nil } -func (m *MockMediaFileRepo) MatchesCriteria(context.Context, string, criteria.Criteria) (bool, error) { - if m.MatchesCriteriaErr != nil { - return false, m.MatchesCriteriaErr - } - return m.MatchesCriteriaValue, nil -} - var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil) -var _ rest.Repository[model.MediaFile] = (*MockMediaFileRepo)(nil) +var _ model.ResourceRepository = (*MockMediaFileRepo)(nil) diff --git a/tests/mock_player_repo.go b/tests/mock_player_repo.go deleted file mode 100644 index 56835f0cc..000000000 --- a/tests/mock_player_repo.go +++ /dev/null @@ -1,75 +0,0 @@ -package tests - -import ( - "context" - "maps" - "slices" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" -) - -func CreateMockPlayerRepo() *MockPlayerRepo { - return &MockPlayerRepo{Data: map[string]*model.Player{}, APIKeys: map[string]string{}} -} - -// MockPlayerRepo keeps API keys in plaintext (key -> player ID); hashing belongs to the real repository. -type MockPlayerRepo struct { - model.PlayerRepository - Error error - Data map[string]*model.Player - APIKeys map[string]string -} - -func (m *MockPlayerRepo) Get(_ context.Context, id string) (*model.Player, error) { - if m.Error != nil { - return nil, m.Error - } - p, ok := m.Data[id] - if !ok { - return nil, model.ErrNotFound - } - cp := *p - cp.HasAPIKey = slices.Contains(slices.Collect(maps.Values(m.APIKeys)), id) - return &cp, nil -} - -func (m *MockPlayerRepo) Put(_ context.Context, p *model.Player) error { - if m.Error != nil { - return m.Error - } - if p.ID == "" { - p.ID = id.NewRandom() - } - cp := *p - m.Data[p.ID] = &cp - return nil -} - -func (m *MockPlayerRepo) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) { - if m.Error != nil { - return nil, m.Error - } - if playerID, ok := m.APIKeys[key]; ok { - return m.Get(ctx, playerID) - } - return nil, model.ErrNotFound -} - -func (m *MockPlayerRepo) SetAPIKey(_ context.Context, playerID, key string) error { - if m.Error != nil { - return m.Error - } - if _, ok := m.Data[playerID]; !ok { - return model.ErrNotFound - } - m.removeKeys(playerID) - if key != "" { - m.APIKeys[key] = playerID - } - return nil -} - -func (m *MockPlayerRepo) removeKeys(playerID string) { - maps.DeleteFunc(m.APIKeys, func(_ string, v string) bool { return v == playerID }) -} diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 824e701f6..9b38ea5b5 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -1,9 +1,7 @@ package tests import ( - "context" "errors" - "time" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" @@ -19,56 +17,19 @@ func CreateMockPlaylistRepo() *MockPlaylistRepo { type MockPlaylistRepo struct { model.PlaylistRepository - Data map[string]*model.Playlist // keyed by ID - PathMap map[string]*model.Playlist // keyed by path - All model.Playlists - Options model.QueryOptions - Last *model.Playlist - Deleted []string - Starred map[string]bool // itemID -> starred - Ratings map[string]int // itemID -> rating - Err bool - TracksRepo model.PlaylistTrackRepository - TracksRefreshed bool + Data map[string]*model.Playlist // keyed by ID + PathMap map[string]*model.Playlist // keyed by path + Last *model.Playlist + Deleted []string + Err bool + TracksRepo model.PlaylistTrackRepository } func (m *MockPlaylistRepo) SetError(err bool) { m.Err = err } -func (m *MockPlaylistRepo) SetData(playlists model.Playlists) { - m.Data = make(map[string]*model.Playlist, len(playlists)) - m.All = playlists - for i, p := range m.All { - m.Data[p.ID] = &m.All[i] - } -} - -func (m *MockPlaylistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Playlists, error) { - if len(options) > 0 { - m.Options = options[0] - } - if m.Err { - return nil, errors.New("error") - } - return m.All, nil -} - -func (m *MockPlaylistRepo) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.PlaylistCursor, error) { - res, err := m.GetAll(ctx, options...) - if err != nil { - return nil, err - } - return func(yield func(model.Playlist, error) bool) { - for _, p := range res { - if !yield(p, nil) { - return - } - } - }, nil -} - -func (m *MockPlaylistRepo) Get(_ context.Context, id string) (*model.Playlist, error) { +func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") } @@ -80,11 +41,11 @@ func (m *MockPlaylistRepo) Get(_ context.Context, id string) (*model.Playlist, e return nil, model.ErrNotFound } -func (m *MockPlaylistRepo) GetWithTracks(ctx context.Context, id string, _, _ bool) (*model.Playlist, error) { - return m.Get(ctx, id) +func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, error) { + return m.Get(id) } -func (m *MockPlaylistRepo) Put(_ context.Context, pls *model.Playlist, _ ...string) error { +func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") } @@ -98,7 +59,7 @@ func (m *MockPlaylistRepo) Put(_ context.Context, pls *model.Playlist, _ ...stri return nil } -func (m *MockPlaylistRepo) FindByPath(_ context.Context, path string) (*model.Playlist, error) { +func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") } @@ -110,58 +71,19 @@ func (m *MockPlaylistRepo) FindByPath(_ context.Context, path string) (*model.Pl return nil, model.ErrNotFound } -func (m *MockPlaylistRepo) Delete(_ context.Context, ids ...string) error { +func (m *MockPlaylistRepo) Delete(id string) error { if m.Err { return errors.New("error") } - m.Deleted = append(m.Deleted, ids...) + m.Deleted = append(m.Deleted, id) return nil } -func (m *MockPlaylistRepo) SetStar(_ context.Context, 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(_ context.Context, 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(context.Context, string, time.Time) error { - if m.Err { - return errors.New("error") - } - return nil -} - -func (m *MockPlaylistRepo) ReassignAnnotation(context.Context, string, string) error { - if m.Err { - return errors.New("error") - } - return nil -} - -func (m *MockPlaylistRepo) Tracks(_ context.Context, _ string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { - m.TracksRefreshed = refreshSmartPlaylist +func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { return m.TracksRepo } -func (m *MockPlaylistRepo) Exists(_ context.Context, id string) (bool, error) { +func (m *MockPlaylistRepo) Exists(id string) (bool, error) { if m.Err { return false, errors.New("error") } @@ -172,14 +94,14 @@ func (m *MockPlaylistRepo) Exists(_ context.Context, id string) (bool, error) { return false, nil } -func (m *MockPlaylistRepo) Count(_ context.Context, _ ...rest.QueryOptions) (int64, error) { +func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) { if m.Err { return 0, errors.New("error") } return int64(len(m.Data)), nil } -func (m *MockPlaylistRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { +func (m *MockPlaylistRepo) CountAll(_ ...model.QueryOptions) (int64, error) { if m.Err { return 0, errors.New("error") } diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index 794393371..c11b077d2 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -1,90 +1,17 @@ package tests -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" -) +import "github.com/navidrome/navidrome/model" type MockPlaylistTrackRepo struct { model.PlaylistTrackRepository - Data model.PlaylistTracks - Options model.QueryOptions AddedIds []string DeletedIds []string Reordered bool AddCount int - InsertPos int Err error - AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options } -func (m *MockPlaylistTrackRepo) SetData(tracks model.PlaylistTracks) { - m.Data = tracks -} - -// page applies Max/Offset as the real repository's SQL would. -func (m *MockPlaylistTrackRepo) page(options ...model.QueryOptions) model.PlaylistTracks { - var opts model.QueryOptions - if len(options) > 0 { - opts = options[0] - m.Options = opts - } - tracks := m.Data - if opts.Offset >= len(tracks) { - return nil - } - tracks = tracks[opts.Offset:] - if opts.Max > 0 && opts.Max < len(tracks) { - tracks = tracks[:opts.Max] - } - return tracks -} - -func (m *MockPlaylistTrackRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { - if m.Err != nil { - return 0, m.Err - } - return int64(len(m.Data)), nil -} - -func (m *MockPlaylistTrackRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.PlaylistTracks, error) { - if m.Err != nil { - return nil, m.Err - } - return m.page(options...), nil -} - -func (m *MockPlaylistTrackRepo) GetCursor(_ context.Context, options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { - if m.Err != nil { - return nil, m.Err - } - tracks := m.page(options...) - return func(yield func(model.PlaylistTrack, error) bool) { - for _, t := range tracks { - if !yield(t, nil) { - return - } - } - }, nil -} - -func (m *MockPlaylistTrackRepo) GetAlbumIDs(context.Context, ...model.QueryOptions) ([]string, error) { - if m.Err != nil { - return nil, m.Err - } - return m.AlbumIDs, nil -} - -func (m *MockPlaylistTrackRepo) GetMediaFileIDs(_ context.Context, options ...model.QueryOptions) ([]string, error) { - if m.Err != nil { - return nil, m.Err - } - return slice.Map(m.page(options...), func(t model.PlaylistTrack) string { return t.MediaFileID }), nil -} - -func (m *MockPlaylistTrackRepo) Add(_ context.Context, ids []string) (int, error) { +func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { m.AddedIds = append(m.AddedIds, ids...) if m.Err != nil { return 0, m.Err @@ -92,38 +19,33 @@ func (m *MockPlaylistTrackRepo) Add(_ context.Context, ids []string) (int, error return m.AddCount, nil } -func (m *MockPlaylistTrackRepo) Insert(ctx context.Context, ids []string, pos int) (int, error) { - m.InsertPos = pos - return m.Add(ctx, ids) -} - -func (m *MockPlaylistTrackRepo) AddAlbums(_ context.Context, _ []string) (int, error) { +func (m *MockPlaylistTrackRepo) AddAlbums(_ []string) (int, error) { if m.Err != nil { return 0, m.Err } return m.AddCount, nil } -func (m *MockPlaylistTrackRepo) AddArtists(_ context.Context, _ []string) (int, error) { +func (m *MockPlaylistTrackRepo) AddArtists(_ []string) (int, error) { if m.Err != nil { return 0, m.Err } return m.AddCount, nil } -func (m *MockPlaylistTrackRepo) AddDiscs(_ context.Context, _ []model.DiscID) (int, error) { +func (m *MockPlaylistTrackRepo) AddDiscs(_ []model.DiscID) (int, error) { if m.Err != nil { return 0, m.Err } return m.AddCount, nil } -func (m *MockPlaylistTrackRepo) Delete(_ context.Context, ids ...string) error { +func (m *MockPlaylistTrackRepo) Delete(ids ...string) error { m.DeletedIds = append(m.DeletedIds, ids...) return m.Err } -func (m *MockPlaylistTrackRepo) Reorder(_ context.Context, _, _ int) error { +func (m *MockPlaylistTrackRepo) Reorder(_, _ int) error { m.Reordered = true return m.Err } diff --git a/tests/mock_playqueue_repo.go b/tests/mock_playqueue_repo.go index b445a5085..19976db57 100644 --- a/tests/mock_playqueue_repo.go +++ b/tests/mock_playqueue_repo.go @@ -1,7 +1,6 @@ package tests import ( - "context" "errors" "github.com/navidrome/navidrome/model" @@ -14,7 +13,7 @@ type MockPlayQueueRepo struct { LastCols []string } -func (m *MockPlayQueueRepo) Store(_ context.Context, q *model.PlayQueue, cols ...string) error { +func (m *MockPlayQueueRepo) Store(q *model.PlayQueue, cols ...string) error { if m.Err { return errors.New("error") } @@ -27,7 +26,7 @@ func (m *MockPlayQueueRepo) Store(_ context.Context, q *model.PlayQueue, cols .. return nil } -func (m *MockPlayQueueRepo) RetrieveWithMediaFiles(_ context.Context, userId string) (*model.PlayQueue, error) { +func (m *MockPlayQueueRepo) RetrieveWithMediaFiles(userId string) (*model.PlayQueue, error) { if m.Err { return nil, errors.New("error") } @@ -41,7 +40,7 @@ func (m *MockPlayQueueRepo) RetrieveWithMediaFiles(_ context.Context, userId str return &qCopy, nil } -func (m *MockPlayQueueRepo) Retrieve(_ context.Context, userId string) (*model.PlayQueue, error) { +func (m *MockPlayQueueRepo) Retrieve(userId string) (*model.PlayQueue, error) { if m.Err { return nil, errors.New("error") } @@ -57,7 +56,7 @@ func (m *MockPlayQueueRepo) Retrieve(_ context.Context, userId string) (*model.P return &qCopy, nil } -func (m *MockPlayQueueRepo) Clear(_ context.Context, userId string) error { +func (m *MockPlayQueueRepo) Clear(userId string) error { if m.Err { return errors.New("error") } diff --git a/tests/mock_plugin_repo.go b/tests/mock_plugin_repo.go index feaf9b212..5d22c26aa 100644 --- a/tests/mock_plugin_repo.go +++ b/tests/mock_plugin_repo.go @@ -1,7 +1,6 @@ package tests import ( - "context" "errors" "time" @@ -30,7 +29,7 @@ func (m *MockPluginRepo) SetError(err bool) { m.Err = err } -func (m *MockPluginRepo) ClearErrors(context.Context) error { +func (m *MockPluginRepo) ClearErrors() error { if m.Err { return errors.New("unexpected error") } @@ -56,7 +55,7 @@ func (m *MockPluginRepo) SetPermitted(permitted bool) { m.Permitted = permitted } -func (m *MockPluginRepo) Get(_ context.Context, id string) (*model.Plugin, error) { +func (m *MockPluginRepo) Get(id string) (*model.Plugin, error) { if !m.Permitted { return nil, rest.ErrPermissionDenied } @@ -69,11 +68,15 @@ func (m *MockPluginRepo) Get(_ context.Context, id string) (*model.Plugin, error return nil, model.ErrNotFound } -func (m *MockPluginRepo) Read(ctx context.Context, id string) (*model.Plugin, error) { - return m.Get(ctx, id) +func (m *MockPluginRepo) Read(id string) (any, error) { + p, err := m.Get(id) + if errors.Is(err, model.ErrNotFound) { + return nil, rest.ErrNotFound + } + return p, err } -func (m *MockPluginRepo) Put(_ context.Context, p *model.Plugin) error { +func (m *MockPluginRepo) Put(p *model.Plugin) error { if !m.Permitted { return rest.ErrPermissionDenied } @@ -106,7 +109,7 @@ func (m *MockPluginRepo) Put(_ context.Context, p *model.Plugin) error { return nil } -func (m *MockPluginRepo) Delete(_ context.Context, id string) error { +func (m *MockPluginRepo) Delete(id string) error { if !m.Permitted { return rest.ErrPermissionDenied } @@ -124,7 +127,7 @@ func (m *MockPluginRepo) Delete(_ context.Context, id string) error { return nil } -func (m *MockPluginRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.Plugins, error) { +func (m *MockPluginRepo) GetAll(qo ...model.QueryOptions) (model.Plugins, error) { if len(qo) > 0 { m.Options = qo[0] } @@ -137,7 +140,7 @@ func (m *MockPluginRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (mo return m.All, nil } -func (m *MockPluginRepo) CountAll(_ context.Context, qo ...model.QueryOptions) (int64, error) { +func (m *MockPluginRepo) CountAll(qo ...model.QueryOptions) (int64, error) { if len(qo) > 0 { m.Options = qo[0] } @@ -150,16 +153,36 @@ func (m *MockPluginRepo) CountAll(_ context.Context, qo ...model.QueryOptions) ( return int64(len(m.All)), nil } -// REST repository methods -func (m *MockPluginRepo) Count(_ context.Context, _ ...rest.QueryOptions) (int64, error) { +// rest.Repository interface methods +func (m *MockPluginRepo) Count(options ...rest.QueryOptions) (int64, error) { if !m.Permitted { return 0, rest.ErrPermissionDenied } return int64(len(m.All)), nil } -func (m *MockPluginRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Plugin, error) { - return m.GetAll(ctx) +func (m *MockPluginRepo) EntityName() string { + return "plugin" +} + +func (m *MockPluginRepo) NewInstance() any { + return &model.Plugin{} +} + +func (m *MockPluginRepo) ReadAll(options ...rest.QueryOptions) (any, error) { + return m.GetAll() +} + +func (m *MockPluginRepo) Save(entity any) (string, error) { + p := entity.(*model.Plugin) + err := m.Put(p) + return p.ID, err +} + +func (m *MockPluginRepo) Update(id string, entity any, cols ...string) error { + p := entity.(*model.Plugin) + p.ID = id + return m.Put(p) } var _ model.PluginRepository = (*MockPluginRepo)(nil) diff --git a/tests/mock_property_repo.go b/tests/mock_property_repo.go index 949f894c1..9adc66e6d 100644 --- a/tests/mock_property_repo.go +++ b/tests/mock_property_repo.go @@ -1,10 +1,6 @@ package tests -import ( - "context" - - "github.com/navidrome/navidrome/model" -) +import "github.com/navidrome/navidrome/model" type MockedPropertyRepo struct { model.PropertyRepository @@ -18,7 +14,7 @@ func (p *MockedPropertyRepo) init() { } } -func (p *MockedPropertyRepo) Put(_ context.Context, id string, value string) error { +func (p *MockedPropertyRepo) Put(id string, value string) error { if p.Error != nil { return p.Error } @@ -27,7 +23,7 @@ func (p *MockedPropertyRepo) Put(_ context.Context, id string, value string) err return nil } -func (p *MockedPropertyRepo) Get(_ context.Context, id string) (string, error) { +func (p *MockedPropertyRepo) Get(id string) (string, error) { if p.Error != nil { return "", p.Error } @@ -38,7 +34,7 @@ func (p *MockedPropertyRepo) Get(_ context.Context, id string) (string, error) { return "", model.ErrNotFound } -func (p *MockedPropertyRepo) Delete(_ context.Context, id string) error { +func (p *MockedPropertyRepo) Delete(id string) error { if p.Error != nil { return p.Error } @@ -50,12 +46,12 @@ func (p *MockedPropertyRepo) Delete(_ context.Context, id string) error { return model.ErrNotFound } -func (p *MockedPropertyRepo) DefaultGet(ctx context.Context, id string, defaultValue string) (string, error) { +func (p *MockedPropertyRepo) DefaultGet(id string, defaultValue string) (string, error) { if p.Error != nil { return "", p.Error } p.init() - v, err := p.Get(ctx, id) + v, err := p.Get(id) if err != nil { return defaultValue, nil //nolint:nilerr } diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index 21898ea53..c50a529e5 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -1,7 +1,6 @@ package tests import ( - "context" "errors" "github.com/navidrome/navidrome/model" @@ -17,34 +16,36 @@ type MockedRadioRepo struct { } func CreateMockedRadioRepo() *MockedRadioRepo { - return &MockedRadioRepo{Data: map[string]*model.Radio{}} + return &MockedRadioRepo{} } func (m *MockedRadioRepo) SetError(err bool) { m.Err = err } -func (m *MockedRadioRepo) CountAll(_ context.Context, options ...model.QueryOptions) (int64, error) { +func (m *MockedRadioRepo) CountAll(options ...model.QueryOptions) (int64, error) { if m.Err { return 0, errors.New("error") } return int64(len(m.Data)), nil } -func (m *MockedRadioRepo) Delete(_ context.Context, ids ...string) error { +func (m *MockedRadioRepo) Delete(id string) error { if m.Err { return errors.New("Error!") } - for _, id := range ids { - if _, found := m.Data[id]; !found { - return errors.New("not found") - } - delete(m.Data, id) + + _, found := m.Data[id] + + if !found { + return errors.New("not found") } + + delete(m.Data, id) return nil } -func (m *MockedRadioRepo) Exists(_ context.Context, id string) (bool, error) { +func (m *MockedRadioRepo) Exists(id string) (bool, error) { if m.Err { return false, errors.New("Error!") } @@ -52,7 +53,7 @@ func (m *MockedRadioRepo) Exists(_ context.Context, id string) (bool, error) { return found, nil } -func (m *MockedRadioRepo) Get(_ context.Context, id string) (*model.Radio, error) { +func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) { if m.Err { return nil, errors.New("Error!") } @@ -62,7 +63,7 @@ func (m *MockedRadioRepo) Get(_ context.Context, id string) (*model.Radio, error return nil, model.ErrNotFound } -func (m *MockedRadioRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.Radios, error) { +func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) { if len(qo) > 0 { m.Options = qo[0] } @@ -72,7 +73,7 @@ func (m *MockedRadioRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (m return m.All, nil } -func (m *MockedRadioRepo) Put(_ context.Context, radio *model.Radio, _ ...string) error { +func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error") } diff --git a/tests/mock_scrobble_buffer_repo.go b/tests/mock_scrobble_buffer_repo.go index 91177eeef..5865f423a 100644 --- a/tests/mock_scrobble_buffer_repo.go +++ b/tests/mock_scrobble_buffer_repo.go @@ -1,7 +1,6 @@ package tests import ( - "context" "sync" "time" @@ -18,7 +17,7 @@ func CreateMockedScrobbleBufferRepo() *MockedScrobbleBufferRepo { return &MockedScrobbleBufferRepo{} } -func (m *MockedScrobbleBufferRepo) UserIDs(_ context.Context, service string) ([]string, error) { +func (m *MockedScrobbleBufferRepo) UserIDs(service string) ([]string, error) { if m.Error != nil { return nil, m.Error } @@ -37,7 +36,7 @@ func (m *MockedScrobbleBufferRepo) UserIDs(_ context.Context, service string) ([ return result, nil } -func (m *MockedScrobbleBufferRepo) Enqueue(_ context.Context, service, userId, mediaFileId string, playTime time.Time) error { +func (m *MockedScrobbleBufferRepo) Enqueue(service, userId, mediaFileId string, playTime time.Time) error { if m.Error != nil { return m.Error } @@ -53,7 +52,7 @@ func (m *MockedScrobbleBufferRepo) Enqueue(_ context.Context, service, userId, m return nil } -func (m *MockedScrobbleBufferRepo) Next(_ context.Context, service, userId string) (*model.ScrobbleEntry, error) { +func (m *MockedScrobbleBufferRepo) Next(service, userId string) (*model.ScrobbleEntry, error) { if m.Error != nil { return nil, m.Error } @@ -67,7 +66,7 @@ func (m *MockedScrobbleBufferRepo) Next(_ context.Context, service, userId strin return nil, nil } -func (m *MockedScrobbleBufferRepo) Dequeue(_ context.Context, entry *model.ScrobbleEntry) error { +func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error { if m.Error != nil { return m.Error } @@ -84,23 +83,7 @@ func (m *MockedScrobbleBufferRepo) Dequeue(_ context.Context, entry *model.Scrob return nil } -func (m *MockedScrobbleBufferRepo) Discard(_ context.Context, 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(context.Context) (int64, error) { +func (m *MockedScrobbleBufferRepo) Length() (int64, error) { if m.Error != nil { return 0, m.Error } @@ -108,5 +91,3 @@ func (m *MockedScrobbleBufferRepo) Length(context.Context) (int64, error) { defer m.mu.RUnlock() return int64(len(m.Data)), nil } - -var _ model.ScrobbleBufferRepository = (*MockedScrobbleBufferRepo)(nil) diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go index a76bc5d59..34561c257 100644 --- a/tests/mock_scrobble_repo.go +++ b/tests/mock_scrobble_repo.go @@ -2,56 +2,23 @@ package tests import ( "context" - "strconv" "time" - "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" ) type MockScrobbleRepo struct { RecordedScrobbles []model.Scrobble + ctx context.Context } -func (m *MockScrobbleRepo) Get(_ context.Context, 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(_ context.Context, _ ...model.QueryOptions) (model.Scrobbles, error) { - return m.RecordedScrobbles, nil -} - -func (m *MockScrobbleRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { - return int64(len(m.RecordedScrobbles)), nil -} - -func (m *MockScrobbleRepo) RecordScrobble(ctx context.Context, fileID string, submissionTime time.Time) error { - user, _ := request.UserFrom(ctx) +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.Unix(), + SubmissionTime: submissionTime, }) return nil } - -func (m *MockScrobbleRepo) Count(ctx context.Context, _ ...rest.QueryOptions) (int64, error) { - return m.CountAll(ctx) -} - -func (m *MockScrobbleRepo) Read(ctx context.Context, id string) (*model.Scrobble, error) { - return m.Get(ctx, id) -} - -func (m *MockScrobbleRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Scrobble, error) { - return m.GetAll(ctx) -} - -var _ model.ScrobbleRepository = (*MockScrobbleRepo)(nil) diff --git a/tests/mock_share_repo.go b/tests/mock_share_repo.go index 0c872dc0d..9fbf0057e 100644 --- a/tests/mock_share_repo.go +++ b/tests/mock_share_repo.go @@ -1,13 +1,14 @@ package tests import ( - "context" - + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" ) type MockShareRepo struct { model.ShareRepository + rest.Repository + rest.Persistable Entity any ID string @@ -15,10 +16,11 @@ type MockShareRepo struct { Error error } -func (m *MockShareRepo) Save(_ context.Context, s *model.Share) (string, error) { +func (m *MockShareRepo) Save(entity any) (string, error) { if m.Error != nil { return "", m.Error } + s := entity.(*model.Share) if s.ID == "" { s.ID = "id" } @@ -26,24 +28,24 @@ func (m *MockShareRepo) Save(_ context.Context, s *model.Share) (string, error) return s.ID, nil } -func (m *MockShareRepo) Update(_ context.Context, id string, entity model.Share, cols ...string) error { +func (m *MockShareRepo) Update(id string, entity any, cols ...string) error { if m.Error != nil { return m.Error } m.ID = id - m.Entity = &entity + m.Entity = entity m.Cols = cols return nil } -func (m *MockShareRepo) Exists(_ context.Context, id string) (bool, error) { +func (m *MockShareRepo) Exists(id string) (bool, error) { if m.Error != nil { return false, m.Error } return id == m.ID, nil } -func (m *MockShareRepo) Get(_ context.Context, id string) (*model.Share, error) { +func (m *MockShareRepo) Get(id string) (*model.Share, error) { if m.Error != nil { return nil, m.Error } diff --git a/tests/mock_tag_repo.go b/tests/mock_tag_repo.go deleted file mode 100644 index f1f252efe..000000000 --- a/tests/mock_tag_repo.go +++ /dev/null @@ -1,26 +0,0 @@ -package tests - -import ( - "context" - - "github.com/navidrome/navidrome/model" -) - -// MockTagRepo records the QueryOptions passed to GetAll, mirroring MockArtistRepo, so tests can -// assert on which filters a caller attached (e.g. a library scope). -type MockTagRepo struct { - model.TagRepository - Data model.TagList - Options model.QueryOptions - Err error -} - -func (r *MockTagRepo) GetAll(_ context.Context, _ model.TagName, options ...model.QueryOptions) (model.TagList, error) { - if len(options) > 0 { - r.Options = options[0] - } - if r.Err != nil { - return nil, r.Err - } - return r.Data, nil -} diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go index 52eb16eed..796e84111 100644 --- a/tests/mock_transcoding_repo.go +++ b/tests/mock_transcoding_repo.go @@ -1,20 +1,16 @@ package tests -import ( - "context" - - "github.com/navidrome/navidrome/model" -) +import "github.com/navidrome/navidrome/model" type MockTranscodingRepo struct { model.TranscodingRepository } -func (m *MockTranscodingRepo) Get(_ context.Context, id string) (*model.Transcoding, error) { +func (m *MockTranscodingRepo) Get(id string) (*model.Transcoding, error) { return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil } -func (m *MockTranscodingRepo) FindByFormat(_ context.Context, format string) (*model.Transcoding, error) { +func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, error) { switch format { case "mp3": return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil @@ -23,9 +19,9 @@ func (m *MockTranscodingRepo) FindByFormat(_ context.Context, format string) (*m 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 -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil + 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 case "aac": - 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 + 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 default: return nil, model.ErrNotFound } diff --git a/tests/mock_user_props_repo.go b/tests/mock_user_props_repo.go index 278a68431..1b1e17650 100644 --- a/tests/mock_user_props_repo.go +++ b/tests/mock_user_props_repo.go @@ -1,10 +1,6 @@ package tests -import ( - "context" - - "github.com/navidrome/navidrome/model" -) +import "github.com/navidrome/navidrome/model" type MockedUserPropsRepo struct { model.UserPropsRepository @@ -18,7 +14,7 @@ func (p *MockedUserPropsRepo) init() { } } -func (p *MockedUserPropsRepo) Put(_ context.Context, userId, key string, value string) error { +func (p *MockedUserPropsRepo) Put(userId, key string, value string) error { if p.Error != nil { return p.Error } @@ -27,7 +23,7 @@ func (p *MockedUserPropsRepo) Put(_ context.Context, userId, key string, value s return nil } -func (p *MockedUserPropsRepo) Get(_ context.Context, userId, key string) (string, error) { +func (p *MockedUserPropsRepo) Get(userId, key string) (string, error) { if p.Error != nil { return "", p.Error } @@ -38,7 +34,7 @@ func (p *MockedUserPropsRepo) Get(_ context.Context, userId, key string) (string return "", model.ErrNotFound } -func (p *MockedUserPropsRepo) Delete(_ context.Context, userId, key string) error { +func (p *MockedUserPropsRepo) Delete(userId, key string) error { if p.Error != nil { return p.Error } @@ -50,12 +46,12 @@ func (p *MockedUserPropsRepo) Delete(_ context.Context, userId, key string) erro return model.ErrNotFound } -func (p *MockedUserPropsRepo) DefaultGet(ctx context.Context, userId, key string, defaultValue string) (string, error) { +func (p *MockedUserPropsRepo) DefaultGet(userId, key string, defaultValue string) (string, error) { if p.Error != nil { return "", p.Error } p.init() - v, err := p.Get(ctx, userId, key) + v, err := p.Get(userId, key) if err != nil { return defaultValue, nil //nolint:nilerr } diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 58c985157..cc05829f6 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -1,13 +1,13 @@ package tests import ( - "context" "encoding/base64" "fmt" "strings" "time" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/gg" ) func CreateMockUserRepo() *MockedUserRepo { @@ -24,14 +24,14 @@ type MockedUserRepo struct { UserLibraries map[string][]int // userID -> libraryIDs } -func (u *MockedUserRepo) CountAll(_ context.Context, qo ...model.QueryOptions) (int64, error) { +func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) { if u.Error != nil { return 0, u.Error } return int64(len(u.Data)), nil } -func (u *MockedUserRepo) Put(_ context.Context, usr *model.User) error { +func (u *MockedUserRepo) Put(usr *model.User) error { if u.Error != nil { return u.Error } @@ -43,7 +43,7 @@ func (u *MockedUserRepo) Put(_ context.Context, usr *model.User) error { return nil } -func (u *MockedUserRepo) FindByUsername(_ context.Context, username string) (*model.User, error) { +func (u *MockedUserRepo) FindByUsername(username string) (*model.User, error) { if u.Error != nil { return nil, u.Error } @@ -54,23 +54,11 @@ func (u *MockedUserRepo) FindByUsername(_ context.Context, username string) (*mo return usr, nil } -func (u *MockedUserRepo) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) { - return u.FindByUsername(ctx, username) +func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.User, error) { + return u.FindByUsername(username) } -func (u *MockedUserRepo) FindFirstAdmin(_ context.Context) (*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(_ context.Context, id string) (*model.User, error) { +func (u *MockedUserRepo) Get(id string) (*model.User, error) { if u.Error != nil { return nil, u.Error } @@ -82,7 +70,7 @@ func (u *MockedUserRepo) Get(_ context.Context, id string) (*model.User, error) return nil, model.ErrNotFound } -func (u *MockedUserRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Users, error) { +func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, error) { if u.Error != nil { return nil, u.Error } @@ -93,20 +81,20 @@ func (u *MockedUserRepo) GetAll(_ context.Context, options ...model.QueryOptions return users, nil } -func (u *MockedUserRepo) UpdateLastLoginAt(_ context.Context, id string) error { +func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { for _, usr := range u.Data { if usr.ID == id { - usr.LastLoginAt = new(time.Now()) + usr.LastLoginAt = gg.P(time.Now()) return nil } } return u.Error } -func (u *MockedUserRepo) UpdateLastAccessAt(_ context.Context, id string) error { +func (u *MockedUserRepo) UpdateLastAccessAt(id string) error { for _, usr := range u.Data { if usr.ID == id { - usr.LastAccessAt = new(time.Now()) + usr.LastAccessAt = gg.P(time.Now()) return nil } } @@ -115,7 +103,7 @@ func (u *MockedUserRepo) UpdateLastAccessAt(_ context.Context, id string) error // Library association methods - mock implementations -func (u *MockedUserRepo) GetUserLibraries(_ context.Context, userID string) (model.Libraries, error) { +func (u *MockedUserRepo) GetUserLibraries(userID string) (model.Libraries, error) { if u.Error != nil { return nil, u.Error } @@ -136,7 +124,7 @@ func (u *MockedUserRepo) GetUserLibraries(_ context.Context, userID string) (mod return libraries, nil } -func (u *MockedUserRepo) SetUserLibraries(_ context.Context, userID string, libraryIDs []int) error { +func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error { if u.Error != nil { return u.Error } @@ -147,19 +135,10 @@ func (u *MockedUserRepo) SetUserLibraries(_ context.Context, userID string, libr return nil } -func (u *MockedUserRepo) Delete(_ context.Context, ids ...string) error { +func (u *MockedUserRepo) Delete(id string) error { if u.Error != nil { return u.Error } - for _, id := range ids { - if err := u.deleteOne(id); err != nil { - return err - } - } - return nil -} - -func (u *MockedUserRepo) deleteOne(id string) error { for key, usr := range u.Data { if usr.ID == id { delete(u.Data, key) @@ -170,17 +149,19 @@ func (u *MockedUserRepo) deleteOne(id string) error { return model.ErrNotFound } -func (u *MockedUserRepo) Save(ctx context.Context, usr *model.User) (string, error) { - if err := u.Put(ctx, usr); err != nil { +func (u *MockedUserRepo) Save(entity any) (string, error) { + usr := entity.(*model.User) + if err := u.Put(usr); err != nil { return "", err } return usr.ID, nil } -func (u *MockedUserRepo) Update(ctx context.Context, id string, entity model.User, _ ...string) error { +func (u *MockedUserRepo) Update(id string, entity any, cols ...string) error { if u.Error != nil { return u.Error } - entity.ID = id - return u.Put(ctx, &entity) + usr := entity.(*model.User) + usr.ID = id + return u.Put(usr) } diff --git a/tests/mock_user_service.go b/tests/mock_user_service.go index bde843d1a..f2700de45 100644 --- a/tests/mock_user_service.go +++ b/tests/mock_user_service.go @@ -1,8 +1,9 @@ package tests import ( + "context" + "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" ) // MockUserService provides a simple wrapper around MockedUserRepo @@ -12,7 +13,7 @@ type MockUserService struct { *MockedUserRepo } -// MockUserRestAdapter adapts MockedUserRepo to the REST repository interface +// MockUserRestAdapter adapts MockedUserRepo to rest.Repository interface type MockUserRestAdapter struct { *MockedUserRepo } @@ -24,6 +25,6 @@ func NewMockUserService() *MockUserService { return &MockUserService{MockedUserRepo: repo} } -func (m *MockUserService) Repository() rest.Repository[model.User] { +func (m *MockUserService) NewRepository(ctx context.Context) rest.Repository { return &MockUserRestAdapter{MockedUserRepo: m.MockedUserRepo} } diff --git a/ui/embed.go b/ui/embed.go index 2d5fcd979..3e2c413b3 100644 --- a/ui/embed.go +++ b/ui/embed.go @@ -5,7 +5,7 @@ import ( "io/fs" ) -//go:embed all:build +//go:embed build/* var filesystem embed.FS func BuildAssets() fs.FS { diff --git a/ui/package-lock.json b/ui/package-lock.json index 0feab6e7d..1f95f14f8 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.4", + "navidrome-music-player": "4.25.2", "prop-types": "^15.8.1", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", @@ -129,7 +129,6 @@ "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", @@ -1744,7 +1743,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1768,7 +1766,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2474,7 +2471,6 @@ "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", @@ -2528,7 +2524,6 @@ "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" @@ -2544,7 +2539,6 @@ "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", @@ -2591,7 +2585,6 @@ "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" }, @@ -3315,7 +3308,6 @@ "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" }, @@ -3368,7 +3360,6 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3390,7 +3381,6 @@ "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", @@ -3560,7 +3550,6 @@ "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", @@ -3894,7 +3883,6 @@ "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" }, @@ -4579,7 +4567,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4955,7 +4942,6 @@ "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" @@ -5836,7 +5822,6 @@ "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", @@ -6420,7 +6405,6 @@ "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" }, @@ -6437,7 +6421,6 @@ "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" } @@ -6852,7 +6835,6 @@ "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -6966,7 +6948,6 @@ "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", @@ -8569,7 +8550,6 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", - "peer": true, "engines": { "node": "*" } @@ -8613,9 +8593,9 @@ "license": "MIT" }, "node_modules/navidrome-music-player": { - "version": "4.25.4", - "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.4.tgz", - "integrity": "sha512-5N7N94aJMIAfKZ0EKFfsHD95q2HBTAnAWyju0JflKnF+2u9Gr3qwnp3ZaaIC2K3z2GA69z0hqK0aOQ40FYsFrw==", + "version": "4.25.2", + "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.2.tgz", + "integrity": "sha512-k7RXHOOKHeJRCsfmpmQ+TkErndckFfvYMjzwVAKZvViw2PL9ubKWziPfruHZVQr4FiJd2oYKEuTNiWZgAK87CA==", "license": "MIT", "dependencies": { "@react-icons/all-files": "^4.1.0", @@ -9318,7 +9298,6 @@ "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", @@ -9410,7 +9389,6 @@ "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", @@ -9825,7 +9803,6 @@ "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" @@ -9907,7 +9884,6 @@ "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", @@ -9980,7 +9956,6 @@ "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" }, @@ -9998,7 +9973,6 @@ "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" }, @@ -10104,7 +10078,6 @@ "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", @@ -10140,7 +10113,6 @@ "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", @@ -10161,7 +10133,6 @@ "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", @@ -10343,7 +10314,6 @@ "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" } @@ -10353,7 +10323,6 @@ "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" } @@ -10613,7 +10582,6 @@ "integrity": "sha512-FAfGj5Ferzyna11iUwGdkYus/Y9d/H75PEpsseP5DZOsEsyPvP/Q7mJiSXhUYSEmyfHPaZyC8EsJCjqzDbtcfg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -11543,7 +11511,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11765,7 +11732,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12010,7 +11976,6 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12135,7 +12100,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12149,7 +12113,6 @@ "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", @@ -12669,7 +12632,6 @@ "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", @@ -12779,7 +12741,6 @@ "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 b02012104..b440f0595 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.4", + "navidrome-music-player": "4.25.2", "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 a2f74491f..96d8ff5fa 100644 Binary files a/ui/public/fonts/Unbounded-Variable.woff2 and b/ui/public/fonts/Unbounded-Variable.woff2 differ diff --git a/ui/public/offline.html b/ui/public/offline.html index e3c44e8d3..72f2d04ae 100644 --- a/ui/public/offline.html +++ b/ui/public/offline.html @@ -5,6 +5,6 @@

It looks like we are having trouble connecting.
-Please check your internet connection, or try again in a moment.

+Please check your internet connection and try again.

\ No newline at end of file diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 4de369394..35eaee3eb 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,12 +1,7 @@ import ReactGA from 'react-ga' import { Provider } from 'react-redux' import { createHashHistory } from 'history' -import { - Admin as RAAdmin, - Resource, - useSetLocale, - useRefresh, -} from 'react-admin' +import { Admin as RAAdmin, Resource } from 'react-admin' import { HotKeys } from 'react-hotkeys' import dataProvider from './dataProvider' import authProvider from './authProvider' @@ -41,7 +36,7 @@ import { transcodingReducer, } from './reducers' import createAdminStore from './store/createAdminStore' -import { i18nProvider, retrieveTranslation } from './i18n' +import { i18nProvider } from './i18n' import config, { shareInfo } from './config' import { keyMap } from './hotkeys' import useChangeThemeColor from './useChangeThemeColor' @@ -49,7 +44,6 @@ 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() @@ -90,24 +84,6 @@ 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 ( @@ -141,7 +117,7 @@ const Admin = (props) => { , permissions === 'admin' ? ( ({ type: SET_NOTIFICATIONS_STATE, @@ -18,8 +16,3 @@ export const setOmittedFields = (obj) => ({ type: SET_OMITTED_FIELDS, data: obj, }) - -export const setSidebarPlaylistsOnlyFavourites = (enabled) => ({ - type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, - data: enabled, -}) diff --git a/ui/src/album/AlbumActions.jsx b/ui/src/album/AlbumActions.jsx index c3b3a4bc2..96cfab09a 100644 --- a/ui/src/album/AlbumActions.jsx +++ b/ui/src/album/AlbumActions.jsx @@ -27,7 +27,7 @@ import { } from '../actions' import { formatBytes } from '../utils' import config from '../config' -import { RefreshMetadataButton, ToggleFieldsMenu } from '../common' +import { ToggleFieldsMenu } from '../common' const useStyles = makeStyles({ toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' }, @@ -138,11 +138,6 @@ const AlbumActions = ({ )} -
{isNotSmall && }
diff --git a/ui/src/album/AlbumActions.test.jsx b/ui/src/album/AlbumActions.test.jsx deleted file mode 100644 index 246a15558..000000000 --- a/ui/src/album/AlbumActions.test.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import AlbumActions from './AlbumActions' - -const { mockConfig, mockPermissions } = vi.hoisted(() => ({ - mockConfig: { - enableSharing: false, - enableDownloads: false, - losslessFormats: 'FLAC,WAV', - }, - mockPermissions: { value: 'admin' }, -})) -vi.mock('../config', () => ({ default: mockConfig })) - -vi.mock('react-redux', () => ({ - useDispatch: () => vi.fn(), - useSelector: () => ({}), -})) - -const mockRefreshMetadata = vi.fn() - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useNotify: () => vi.fn(), - useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), - usePermissions: () => ({ permissions: mockPermissions.value }), - useTranslate: () => (x) => x, - } -}) - -describe('AlbumActions', () => { - const record = { id: 'al1', name: 'Album', size: 1024 } - const refreshLabel = 'resources.album.actions.refresh' - - beforeEach(() => { - vi.clearAllMocks() - mockPermissions.value = 'admin' - mockRefreshMetadata.mockResolvedValue({ data: { id: 'al1' } }) - }) - - const renderAlbumActions = () => - render( - - - , - ) - - it('refreshes the album metadata for admins', async () => { - renderAlbumActions() - fireEvent.click(screen.getByRole('button', { name: refreshLabel })) - - await waitFor(() => - expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1'), - ) - }) - - it('hides the action for non-admin users', () => { - mockPermissions.value = 'regular' - renderAlbumActions() - expect( - screen.queryByRole('button', { name: refreshLabel }), - ).not.toBeInTheDocument() - }) -}) diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index 8d636a047..cec66eb8b 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Card, CardContent, + CardMedia, Collapse, makeStyles, Typography, @@ -16,12 +17,10 @@ import { useRecordContext, useTranslate, } from 'react-admin' -import clsx from 'clsx' import Lightbox from 'react-image-lightbox' import config from '../config' import 'react-image-lightbox/style.css' import subsonic from '../subsonic' -import { Artwork } from '../common/Artwork' import { ArtistLinkField, CollapsibleComment, @@ -31,8 +30,8 @@ import { RatingField, SizeField, useAlbumsPerPage, + useImageLoadingState, } from '../common' -import { useDateLocale } from '../i18n/useDateLocale' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' import { SafeHTML } from '../common/SafeHTML' @@ -80,9 +79,6 @@ const useStyles = makeStyles( alignItems: 'center', justifyContent: 'center', }, - noCoverAnimation: { - '&, &::before, &::after': { animation: 'none' }, - }, cover: { objectFit: 'contain', cursor: 'pointer', @@ -92,6 +88,9 @@ const useStyles = makeStyles( backgroundColor: 'transparent', transition: 'opacity 0.3s ease-in-out', }, + coverLoading: { + opacity: 0.5, + }, loveButton: { top: theme.spacing(-0.2), left: theme.spacing(0.5), @@ -156,7 +155,6 @@ export const Details = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const translate = useTranslate() const record = useRecordContext(props) - const locale = useDateLocale() // Create an array of detail elements let details = [] @@ -167,13 +165,12 @@ export const Details = (props) => { // Calculate date related fields const yearRange = formatRange(record, 'year') - const date = record.date ? formatFullDate(record.date, locale) : yearRange + const date = record.date ? formatFullDate(record.date) : yearRange const originalDate = record.originalDate - ? formatFullDate(record.originalDate, locale) + ? formatFullDate(record.originalDate) : formatRange(record, 'originalYear') - const releaseDate = - record?.releaseDate && formatFullDate(record.releaseDate, locale) + const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate) const dateToUse = originalDate || date const isOriginalDate = originalDate && dateToUse !== date @@ -226,7 +223,15 @@ const AlbumDetails = (props) => { const classes = useStyles() const [expanded, setExpanded] = useState(false) const [albumInfo, setAlbumInfo] = useState() - const [isLightboxOpen, setLightboxOpen] = useState(false) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useImageLoadingState(record.id) let notes = albumInfo?.notes || record.notes @@ -249,23 +254,27 @@ const AlbumDetails = (props) => { }) }, [record]) + const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize) const fullImageUrl = subsonic.getCoverArtUrl(record) return (
-
- + setLightboxOpen(true)} + style={{ + cursor: imageError ? 'default' : 'pointer', + }} />
@@ -354,13 +363,13 @@ const AlbumDetails = (props) => {
)} - {isLightboxOpen && ( + {isLightboxOpen && !imageError && ( setLightboxOpen(false)} + onCloseRequest={handleCloseLightbox} /> )} diff --git a/ui/src/album/AlbumDetails.test.jsx b/ui/src/album/AlbumDetails.test.jsx index afc4bf0a0..484045444 100644 --- a/ui/src/album/AlbumDetails.test.jsx +++ b/ui/src/album/AlbumDetails.test.jsx @@ -3,29 +3,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest' import { render } from '@testing-library/react' import { RecordContextProvider } from 'react-admin' import { useMediaQuery } from '@material-ui/core' -import { createTheme, ThemeProvider } from '@material-ui/core/styles' -import config from '../config' -import AlbumDetails, { Details } from './AlbumDetails' - -vi.mock('../subsonic', () => ({ - default: { - getAlbumInfo: () => - Promise.resolve({ - json: { 'subsonic-response': { status: 'ok', albumInfo: {} } }, - }), - getCoverArtUrl: () => '', - }, -})) - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - useDataProvider: () => ({ getOne: vi.fn() }), - useNotify: () => vi.fn(), - useRefresh: () => vi.fn(), - } -}) +import { Details } from './AlbumDetails' // Mock useMediaQuery vi.mock('@material-ui/core', async () => { @@ -365,49 +343,3 @@ describe('Details component', () => { }) }) }) - -describe('AlbumDetails cover animation', () => { - const albumRecord = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - } - const originalEnableCoverAnimation = config.enableCoverAnimation - - beforeEach(() => { - vi.mocked(useMediaQuery).mockReturnValue(false) - }) - - afterEach(() => { - config.enableCoverAnimation = originalEnableCoverAnimation - }) - - const renderAlbum = () => - render( - - - - - , - ) - - test('applies noCoverAnimation when enableCoverAnimation is false', () => { - config.enableCoverAnimation = false - const { container } = renderAlbum() - const cover = container.querySelector('[class*="coverParent"]') - - expect(cover).not.toBeNull() - expect(cover.className).toMatch(/noCoverAnimation/) - }) - - test('omits noCoverAnimation when enableCoverAnimation is true', () => { - config.enableCoverAnimation = true - const { container } = renderAlbum() - const cover = container.querySelector('[class*="coverParent"]') - - expect(cover).not.toBeNull() - expect(cover.className).not.toMatch(/noCoverAnimation/) - }) -}) diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 2e4cb10ba..33db9e40f 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react' +import React from 'react' import { GridList, GridListTile, @@ -11,18 +11,19 @@ import withWidth from '@material-ui/core/withWidth' import { Link } from 'react-router-dom' import { linkToRecord, useListContext, Loading } from 'react-admin' import { withContentRect } from 'react-measure' -import { useRollChanged } from './useRollChanged' import { useDrag } from 'react-dnd' +import subsonic from '../subsonic' import { AlbumContextMenu, PlayButton, ArtistLinkField, OverflowTooltip, + useImageUrl, } from '../common' +import config from '../config' import { DraggableTypes } from '../consts' import clsx from 'clsx' import { AlbumDatesField } from './AlbumDatesField.jsx' -import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -104,12 +105,12 @@ const useCoverStyles = makeStyles({ display: 'inline-block', width: '100%', objectFit: 'contain', - // The image fills this box absolutely, so it lends no height: a remount that has not been - // re-measured yet would collapse the tile and blank the cover for a frame. - aspectRatio: '1', height: (props) => props.height, transition: 'opacity 0.3s ease-in-out', }, + coverLoading: { + opacity: 0, + }, }) const getColsForWidth = (width) => { @@ -137,14 +138,16 @@ const Cover = withContentRect('bounds')(({ [record], ) + const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) + const { imgUrl, loading: imageLoading } = useImageUrl(url) + return (
-
@@ -234,21 +237,9 @@ const LoadedAlbumGrid = ({ ids, data, basePath, width }) => { ) } -const AlbumGridView = ({ - albumListType, - loaded, - loading, - seed, - shownSeed, - ...props -}) => { - // ArtistShow renders this grid too, with no roll to track, so own a ref when none is passed. - const ownSeed = useRef(null) - // A re-roll replaces every album, so the previous roll must not linger while it loads. - const rerolling = - useRollChanged(shownSeed ?? ownSeed, seed, loading) && - albumListType === 'random' - const hide = rerolling || !props.data || !props.ids +const AlbumGridView = ({ albumListType, loaded, loading, ...props }) => { + const hide = + (loading && albumListType === 'random') || !props.data || !props.ids return hide ? : } diff --git a/ui/src/album/AlbumGridView.test.jsx b/ui/src/album/AlbumGridView.test.jsx deleted file mode 100644 index 1f06c3fd7..000000000 --- a/ui/src/album/AlbumGridView.test.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { render } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' -import AlbumGridView from './AlbumGridView' - -// react-admin's Link/useListContext need a router and a store; the grid body is not under test here. -vi.mock('react-admin', () => ({ - linkToRecord: () => '/album/1', - useListContext: () => ({}), - Loading: () =>
, -})) -describe('AlbumGridView', () => { - // ArtistShow renders the grid through ReferenceManyField, which passes no seed tracking. - it('renders without a shownSeed ref', () => { - expect(() => - render(), - ).not.toThrow() - }) -}) diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 81e4dcd14..0b8c256df 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -1,4 +1,3 @@ -import { cloneElement, useRef } from 'react' import { useSelector } from 'react-redux' import { Redirect, useLocation } from 'react-router-dom' import { @@ -7,6 +6,7 @@ import { Filter, NullableBooleanInput, NumberInput, + Pagination, ReferenceArrayInput, ReferenceInput, SearchInput, @@ -20,35 +20,21 @@ import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { List, - Pagination, Title, useAlbumsPerPage, useResourceRefresh, - useScrollRestoration, useSetToggleableFields, } from '../common' import AlbumListActions from './AlbumListActions' import AlbumTableView from './AlbumTableView' import AlbumGridView from './AlbumGridView' -import { useRollChanged } from './useRollChanged' -import albumLists from './albumLists' -import { - getStoredDefaultView, - isResourceDefaultView, -} from '../personal/defaultViews' +import albumLists, { defaultAlbumList } from './albumLists' import config from '../config' import AlbumInfo from './AlbumInfo' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import { humanize } from 'inflection' import { makeStyles } from '@material-ui/core/styles' -// Waits for rows: restoring into an unrendered list leaves the page too short to hold the offset. -const ScrollRestorer = ({ children, ...rest }) => { - const { loaded, total } = useListContext() - useScrollRestoration(loaded && total > 0) - return cloneElement(children, rest) -} - const useStyles = makeStyles({ chip: { margin: 0, @@ -163,7 +149,6 @@ const AlbumFilter = (props) => { /> - {config.enableFavourites && ( { return } -const AlbumListPagination = ({ albumListType, seed, shownSeed, ...rest }) => { +const AlbumListPagination = ({ albumListType, ...rest }) => { const { loading } = useListContext() - const rerolling = useRollChanged(shownSeed, seed, loading) - if (rerolling && albumListType === 'random') { + if (loading && albumListType === 'random') { return null } return <Pagination {...rest} /> @@ -201,7 +185,6 @@ const randomStartingSeed = Math.random().toString() const AlbumList = (props) => { const { width } = props - const shownSeed = useRef(null) const albumView = useSelector((state) => state.albumView) const [perPage, perPageOptions] = useAlbumsPerPage(width) const location = useLocation() @@ -237,10 +220,8 @@ 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 || getStoredDefaultView() - if (isResourceDefaultView(type)) { - return <Redirect to={`/${type}`} /> - } + const type = + albumListType || localStorage.getItem('defaultView') || defaultAlbumList const listParams = albumLists[type] if (type === 'random') { refresh() @@ -264,24 +245,15 @@ const AlbumList = (props) => { <AlbumListPagination rowsPerPageOptions={perPageOptions} albumListType={albumListType} - seed={seed} - shownSeed={shownSeed} /> } title={<AlbumListTitle albumListType={albumListType} />} > - <ScrollRestorer> - {albumView.grid ? ( - <AlbumGridView - albumListType={albumListType} - seed={seed} - shownSeed={shownSeed} - {...props} - /> - ) : ( - <AlbumTableView {...props} /> - )} - </ScrollRestorer> + {albumView.grid ? ( + <AlbumGridView albumListType={albumListType} {...props} /> + ) : ( + <AlbumTableView {...props} /> + )} </List> <ExpandInfoDialog content={<AlbumInfo />} /> </> diff --git a/ui/src/album/AlbumShow.jsx b/ui/src/album/AlbumShow.jsx index 86b5ab1bc..c9e944999 100644 --- a/ui/src/album/AlbumShow.jsx +++ b/ui/src/album/AlbumShow.jsx @@ -10,7 +10,7 @@ import { makeStyles } from '@material-ui/core/styles' import AlbumSongs from './AlbumSongs' import AlbumDetails from './AlbumDetails' import AlbumActions from './AlbumActions' -import { useResourceRefresh, useScrollRestoration, Title } from '../common' +import { useResourceRefresh, Title } from '../common' const useStyles = makeStyles( (theme) => ({ @@ -28,7 +28,6 @@ const AlbumShowLayout = (props) => { const { record } = context const classes = useStyles() useResourceRefresh('album', 'song') - useScrollRestoration(!!record?.id) return ( <> diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx index 051d5988e..d1a89d512 100644 --- a/ui/src/album/AlbumTableView.jsx +++ b/ui/src/album/AlbumTableView.jsx @@ -14,7 +14,7 @@ import { makeStyles } from '@material-ui/core/styles' import { useDrag } from 'react-dnd' import { ArtistLinkField, - ArtworkAvatar, + CoverArtAvatar, DurationField, RangeField, SimpleList, @@ -164,7 +164,7 @@ const AlbumTableView = ({ )} leftIcon={(r) => ( <span style={{ marginRight: '8px' }}> - <ArtworkAvatar record={r} variant="square" /> + <CoverArtAvatar record={r} variant="square" /> </span> )} linkType={'show'} @@ -173,7 +173,7 @@ const AlbumTableView = ({ /> ) : ( <AlbumDatagrid rowClick={'show'} classes={{ row: classes.row }} {...rest}> - <ArtworkAvatar source="id" variant="square" /> + <CoverArtAvatar source="id" variant="square" /> <TextField source="name" /> {columns} <AlbumContextMenu diff --git a/ui/src/album/useRollChanged.jsx b/ui/src/album/useRollChanged.jsx deleted file mode 100644 index b364bdc6b..000000000 --- a/ui/src/album/useRollChanged.jsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useRef } from 'react' - -// Reports that the albums on screen belong to a different roll than the one loading: only a seed -// change is a re-roll. `shown` is owned above the grid, which a refresh remounts under the new seed. -export const useRollChanged = (shown, seed, loading) => { - const wasLoading = useRef(loading) - - if (!loading && (shown.current === null || wasLoading.current)) { - shown.current = seed - } - wasLoading.current = loading - return shown.current !== seed -} diff --git a/ui/src/album/useRollChanged.test.jsx b/ui/src/album/useRollChanged.test.jsx deleted file mode 100644 index 0745efb08..000000000 --- a/ui/src/album/useRollChanged.test.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks' -import { describe, it, expect } from 'vitest' -import { useRollChanged } from './useRollChanged' - -describe('useRollChanged', () => { - // Passing `shown` in mimics AlbumList owning it across a remount. - const setup = (props, shown = { current: null }) => ({ - shown, - ...renderHook(({ seed, loading }) => useRollChanged(shown, seed, loading), { - initialProps: props, - }), - }) - - it('reports a change while a load is in flight on a fresh mount', () => { - const { result } = setup({ seed: 's1', loading: true }) - expect(result.current).toBe(true) - }) - - it('reports no change when mounted with data already in hand', () => { - const { result } = setup({ seed: 's1', loading: false }) - expect(result.current).toBe(false) - }) - - it('stays false while loading a filter change on the same roll', () => { - const { result, rerender } = setup({ seed: 's1', loading: false }) - rerender({ seed: 's1', loading: true }) - expect(result.current).toBe(false) - }) - - it('goes true while loading after the seed changes', () => { - const { result, rerender } = setup({ seed: 's1', loading: false }) - rerender({ seed: 's2', loading: true }) - expect(result.current).toBe(true) - }) - - it('clears once the new roll has loaded', () => { - const { result, rerender } = setup({ seed: 's1', loading: false }) - rerender({ seed: 's2', loading: true }) - expect(result.current).toBe(true) - rerender({ seed: 's2', loading: false }) - expect(result.current).toBe(false) - }) - - // The seed can land a render before loading flips; the new roll must not be recorded as shown. - it('still reports a change when the seed arrives before loading starts', () => { - const { result, rerender } = setup({ seed: 's1', loading: false }) - rerender({ seed: 's2', loading: false }) - expect(result.current).toBe(true) - }) - - // Refresh remounts the grid under the new seed a render before the refetch starts. - it('reports a change when a refresh remounts the grid before loading starts', () => { - const { shown, unmount } = setup({ seed: 's1', loading: false }) - expect(shown.current).toBe('s1') - unmount() - - const remounted = setup({ seed: 's2', loading: false }, shown) - expect(remounted.result.current).toBe(true) - - remounted.rerender({ seed: 's2', loading: true }) - expect(remounted.result.current).toBe(true) - remounted.rerender({ seed: 's2', loading: false }) - expect(remounted.result.current).toBe(false) - }) - - it('reports no change when a remount keeps the same roll', () => { - const { shown, unmount } = setup({ seed: 's1', loading: false }) - unmount() - - const remounted = setup({ seed: 's1', loading: false }, shown) - expect(remounted.result.current).toBe(false) - }) -}) diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index c2614c05a..0b48f232d 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -13,20 +13,9 @@ import { } from 'react-admin' import ShuffleIcon from '@material-ui/icons/Shuffle' import PlayArrowIcon from '@material-ui/icons/PlayArrow' -import ShareIcon from '@material-ui/icons/Share' -import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined' import { IoIosRadio } from 'react-icons/io' import { playShuffle, playTopSongs } from './actions.js' import { playSimilar } from '../common/playbackActions.js' -import { - openShareMenu, - openDownloadMenu, - DOWNLOAD_MENU_ARTIST, -} from '../actions' -import config from '../config' -import { formatBytes } from '../utils' -import { artistDownloadSize } from '../common/artist' -import { RefreshMetadataButton } from '../common/RefreshMetadataButton' const useStyles = makeStyles((theme) => ({ toolbar: { @@ -34,7 +23,6 @@ const useStyles = makeStyles((theme) => ({ padding: '0 !important', background: 'transparent', boxShadow: 'none', - alignItems: 'center', '& .MuiToolbar-root': { minHeight: 'auto', padding: '0 !important', @@ -74,9 +62,6 @@ const ArtistActions = ({ className, record, ...rest }) => { const [loadingAction, setLoadingAction] = React.useState(null) const isLoading = !!loadingAction - const albumArtistSize = artistDownloadSize(record) - const hasAlbumArtistContent = Boolean(albumArtistSize) - const handlePlay = React.useCallback(async () => { setLoadingAction('play') try { @@ -116,14 +101,6 @@ const ArtistActions = ({ className, record, ...rest }) => { } }, [dispatch, notify, record]) - const handleShare = React.useCallback(() => { - dispatch(openShareMenu([record.id], 'artist', record.name)) - }, [dispatch, record]) - - const handleDownload = React.useCallback(() => { - dispatch(openDownloadMenu(record, DOWNLOAD_MENU_ARTIST)) - }, [dispatch, record]) - return ( <TopToolbar className={`${className} ${classes.toolbar}`} @@ -156,31 +133,6 @@ const ArtistActions = ({ className, record, ...rest }) => { loading={loadingAction === 'radio'} icon={<IoIosRadio className={classes.radioIcon} />} /> - {config.enableSharing && hasAlbumArtistContent && ( - <LoadingButton - onClick={handleShare} - label={translate('ra.action.share')} - className={classes.button} - size={isMobile ? 'small' : 'medium'} - icon={<ShareIcon />} - /> - )} - {config.enableDownloads && hasAlbumArtistContent && ( - <LoadingButton - onClick={handleDownload} - label={`${translate('ra.action.download')} (${formatBytes( - albumArtistSize, - )})`} - className={classes.button} - size={isMobile ? 'small' : 'medium'} - icon={<CloudDownloadOutlinedIcon />} - /> - )} - <RefreshMetadataButton - resource="artist" - record={record} - size={isMobile ? 'small' : 'medium'} - /> </TopToolbar> ) } diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx index 05f53f835..a11ee50e3 100644 --- a/ui/src/artist/ArtistActions.test.jsx +++ b/ui/src/artist/ArtistActions.test.jsx @@ -4,11 +4,6 @@ import { TestContext } from 'ra-test' import { describe, it, expect, vi, beforeEach } from 'vitest' import ArtistActions from './ArtistActions' import subsonic from '../subsonic' -import { - openShareMenu, - openDownloadMenu, - DOWNLOAD_MENU_ARTIST, -} from '../actions' import { ThemeProvider, createTheme } from '@material-ui/core/styles' const mockDispatch = vi.fn() @@ -18,36 +13,21 @@ vi.mock('../subsonic', () => ({ default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() }, })) -const { mockConfig, mockPermissions } = vi.hoisted(() => ({ - mockConfig: { enableSharing: true, enableDownloads: true }, - mockPermissions: { value: 'admin' }, -})) -vi.mock('../config', () => ({ default: mockConfig })) - const mockNotify = vi.fn() const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) -const mockRefreshMetadata = vi.fn().mockResolvedValue({ data: { id: 'ar1' } }) vi.mock('react-admin', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useNotify: () => mockNotify, - useDataProvider: () => ({ - getList: mockGetList, - refreshMetadata: mockRefreshMetadata, - }), - usePermissions: () => ({ permissions: mockPermissions.value }), + useDataProvider: () => ({ getList: mockGetList }), useTranslate: () => (x) => x, } }) describe('ArtistActions', () => { - const defaultRecord = { - id: 'ar1', - name: 'Artist', - stats: { albumartist: { songCount: 3, albumCount: 1, size: 1024 } }, - } + const defaultRecord = { id: 'ar1', name: 'Artist' } const renderArtistActions = (record = defaultRecord) => { const theme = createTheme() @@ -68,9 +48,6 @@ describe('ArtistActions', () => { vi.clearAllMocks() // Mock console.error to suppress error logging in tests vi.spyOn(console, 'error').mockImplementation(() => {}) - mockConfig.enableSharing = true - mockConfig.enableDownloads = true - mockPermissions.value = 'admin' const songWithReplayGain = { id: 'rec1', @@ -250,72 +227,4 @@ describe('ArtistActions', () => { expect(mockDispatch).not.toHaveBeenCalled() }) }) - - describe('Share action', () => { - it('shows the share button and dispatches openShareMenu when clicked', () => { - renderArtistActions() - fireEvent.click(screen.getByText('ra.action.share')) - expect(mockDispatch).toHaveBeenCalledWith( - openShareMenu(['ar1'], 'artist', 'Artist'), - ) - }) - - it('hides the share button when sharing is disabled', () => { - mockConfig.enableSharing = false - renderArtistActions() - expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() - }) - }) - - describe('Download action', () => { - it('shows the download button with album-artist size and dispatches openDownloadMenu when clicked', () => { - renderArtistActions() - expect(screen.getByText('ra.action.download (1 KB)')).toBeInTheDocument() - fireEvent.click(screen.getByText(/ra\.action\.download/)) - expect(mockDispatch).toHaveBeenCalledWith( - openDownloadMenu(defaultRecord, DOWNLOAD_MENU_ARTIST), - ) - }) - - it('hides the download button when downloads are disabled', () => { - mockConfig.enableDownloads = false - renderArtistActions() - expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() - }) - }) - - describe('Album-artist gating', () => { - it('hides Share and Download for artists with no album-artist content', () => { - renderArtistActions({ id: 'ar1', name: 'Artist', stats: {} }) - expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() - expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() - }) - - it('hides Share and Download for a missing artist', () => { - renderArtistActions({ ...defaultRecord, missing: true }) - expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() - expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() - }) - }) - - describe('Refresh metadata action', () => { - const refreshLabel = 'resources.album.actions.refresh' - - it('refreshes the artist metadata for admins', async () => { - renderArtistActions() - fireEvent.click(screen.getByRole('button', { name: refreshLabel })) - - await waitFor(() => - expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar1'), - ) - }) - - it('hides the action for non-admin users', () => { - mockPermissions.value = 'regular' - renderArtistActions() - expect( - screen.queryByRole('button', { name: refreshLabel }), - ).not.toBeInTheDocument() - }) - }) }) diff --git a/ui/src/artist/ArtistExternalLink.jsx b/ui/src/artist/ArtistExternalLink.jsx index 6595d1fbc..a83972f17 100644 --- a/ui/src/artist/ArtistExternalLink.jsx +++ b/ui/src/artist/ArtistExternalLink.jsx @@ -38,15 +38,15 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { } if (config.lastFMEnabled) { - // No agent may be enabled to supply a canonical URL, so fall back to a name-based one. - const lastFMUrl = - (lastFMlink && isLastFmURL(lastFMlink[2]) && lastFMlink[2]) || - (isLastFmURL(artistInfo?.lastFmUrl) && artistInfo.lastFmUrl) || - (record.name && - `https://last.fm/music/${encodeURIComponent(record.name)}`) - if (lastFMUrl) { + if (lastFMlink && isLastFmURL(lastFMlink[2])) { addLink( - lastFMUrl, + lastFMlink[2], + 'message.openIn.lastfm', + <ImLastfm2 className="lastfm-icon" />, + ) + } else if (isLastFmURL(artistInfo?.lastFmUrl)) { + addLink( + artistInfo?.lastFmUrl, 'message.openIn.lastfm', <ImLastfm2 className="lastfm-icon" />, ) diff --git a/ui/src/artist/ArtistExternalLink.test.jsx b/ui/src/artist/ArtistExternalLink.test.jsx deleted file mode 100644 index 4214c21ea..000000000 --- a/ui/src/artist/ArtistExternalLink.test.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { TestContext } from 'ra-test' -import ArtistExternalLinks from './ArtistExternalLink' - -const { mockConfig } = vi.hoisted(() => ({ - mockConfig: { lastFMEnabled: true }, -})) -vi.mock('../config', () => ({ default: mockConfig })) - -describe('ArtistExternalLinks', () => { - beforeEach(() => { - mockConfig.lastFMEnabled = true - }) - - const renderLinks = (artistInfo, record = { id: 'ar-1', name: 'Björk' }) => - render( - <TestContext> - <ArtistExternalLinks artistInfo={artistInfo} record={record} /> - </TestContext>, - ) - - const lastFmHref = () => - screen.getByLabelText('message.openIn.lastfm').closest('a').href - - it('uses the URL returned by the server', () => { - renderLinks({ lastFmUrl: 'https://www.last.fm/music/Bjork' }) - expect(lastFmHref()).toBe('https://www.last.fm/music/Bjork') - }) - - it('uses the URL found in the biography', () => { - renderLinks({ - biography: 'Read more on <a href="https://www.last.fm/music/Bjork">', - lastFmUrl: 'https://bjork.com', - }) - expect(lastFmHref()).toBe('https://www.last.fm/music/Bjork') - }) - - it('builds the URL from the artist name when the server has none', () => { - renderLinks({ lastFmUrl: 'https://bjork.com' }) - expect(lastFmHref()).toBe('https://last.fm/music/Bj%C3%B6rk') - }) - - it('builds the URL when there is no artist info at all', () => { - renderLinks(undefined) - expect(lastFmHref()).toBe('https://last.fm/music/Bj%C3%B6rk') - }) - - it('shows no Last.fm link when Last.fm is disabled', () => { - mockConfig.lastFMEnabled = false - renderLinks({ lastFmUrl: 'https://www.last.fm/music/Bjork' }) - expect(screen.queryByLabelText('message.openIn.lastfm')).toBeNull() - }) - - it('shows no Last.fm link when the artist has no name', () => { - renderLinks({}, { id: 'ar-1', name: '' }) - expect(screen.queryByLabelText('message.openIn.lastfm')).toBeNull() - }) -}) diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index f4147221e..4559aa8e6 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -22,7 +22,7 @@ import { useDrag } from 'react-dnd' import clsx from 'clsx' import { ArtistContextMenu, - ArtworkAvatar, + CoverArtAvatar, List, useGetHandleArtistClick, RatingField, @@ -173,7 +173,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { /> ) : ( <ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}> - <ArtworkAvatar source="id" /> + <CoverArtAvatar source="id" /> <TextField source="name" /> <FunctionField source="albumCount" diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index 515c1ab08..935b0bab7 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -13,12 +13,7 @@ import subsonic from '../subsonic' import AlbumGridView from '../album/AlbumGridView' import MobileArtistDetails from './MobileArtistDetails' import DesktopArtistDetails from './DesktopArtistDetails' -import { - useAlbumsPerPage, - useResourceRefresh, - useScrollRestoration, - Title, -} from '../common/index.js' +import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js' import ArtistActions from './ArtistActions' import { makeStyles } from '@material-ui/core' @@ -53,7 +48,7 @@ const useStyles = makeStyles( }, ) -export const ArtistDetails = (props) => { +const ArtistDetails = (props) => { const record = useRecordContext(props) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm'), { noSsr: true, @@ -75,9 +70,7 @@ export const ArtistDetails = (props) => { // eslint-disable-next-line no-console console.error('error on artist page', e) }) - // Keyed on the record, not its id: a refreshed record must re-fetch, or the stale - // artistInfo state keeps winning the `||` above. - }, [record]) + }, [record.id]) const Component = isDesktop ? DesktopArtistDetails : MobileArtistDetails return ( @@ -92,7 +85,6 @@ const ArtistShowLayout = (props) => { const [, perPageOptions] = useAlbumsPerPage(width) const classes = useStyles() useResourceRefresh('artist', 'album') - useScrollRestoration(!!record?.id) const maxPerPage = 90 let perPage = 0 @@ -108,7 +100,6 @@ const ArtistShowLayout = (props) => { const rowsPerPageOptions = [1, 2, 3].map((option) => Math.trunc(option * (perPage / 3)), ) - // react-admin's Pagination on purpose: the common one would persist 30/60/90 under the album grid's key pagination = <Pagination rowsPerPageOptions={rowsPerPageOptions} /> } diff --git a/ui/src/artist/ArtistShow.test.jsx b/ui/src/artist/ArtistShow.test.jsx deleted file mode 100644 index f51cde5ed..000000000 --- a/ui/src/artist/ArtistShow.test.jsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react' -import { render, waitFor } from '@testing-library/react' -import { RecordContextProvider } from 'react-admin' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { ArtistDetails } from './ArtistShow' -import subsonic from '../subsonic' - -vi.mock('../subsonic', () => ({ - default: { getArtistInfo: vi.fn(), getCoverArtUrl: vi.fn() }, -})) - -// Not under test here: isolate ArtistDetails from the leaf presentational views. -vi.mock('./DesktopArtistDetails', () => ({ default: () => null })) -vi.mock('./MobileArtistDetails', () => ({ default: () => null })) - -const mockGetArtistInfo = subsonic.getArtistInfo - -describe('ArtistDetails', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetArtistInfo.mockResolvedValue({ - json: { - 'subsonic-response': { - status: 'ok', - artistInfo: { biography: 'fetched' }, - }, - }, - }) - }) - - const theme = createTheme() - - const wrap = (record) => ( - <ThemeProvider theme={theme}> - <RecordContextProvider value={record}> - <ArtistDetails /> - </RecordContextProvider> - </ThemeProvider> - ) - - const renderDetails = (record) => render(wrap(record)) - - it('re-fetches the artist info when the record object changes', async () => { - const record = { id: 'ar1', name: 'Artist', biography: 'old' } - const { rerender } = renderDetails(record) - await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(1)) - - rerender(wrap({ ...record, biography: 'new' })) - - await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(2)) - }) - - it('does not re-fetch when the same record object is passed again', async () => { - const record = { id: 'ar1', name: 'Artist', biography: 'old' } - const { rerender } = renderDetails(record) - await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(1)) - - rerender(wrap(record)) - - expect(mockGetArtistInfo).toHaveBeenCalledTimes(1) - }) -}) diff --git a/ui/src/artist/ArtistSimpleList.jsx b/ui/src/artist/ArtistSimpleList.jsx index 9dcae0470..55b6b1a0b 100644 --- a/ui/src/artist/ArtistSimpleList.jsx +++ b/ui/src/artist/ArtistSimpleList.jsx @@ -8,7 +8,7 @@ import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' import ListItemText from '@material-ui/core/ListItemText' import { makeStyles } from '@material-ui/core/styles' import { sanitizeListRestProps } from 'react-admin' -import { ArtistContextMenu, ArtworkAvatar, RatingField } from '../common' +import { ArtistContextMenu, CoverArtAvatar, RatingField } from '../common' import config from '../config' const useStyles = makeStyles( @@ -49,7 +49,7 @@ const ArtistSimpleList = ({ <span key={id} onClick={() => linkType(id)}> <ListItem className={classes.listItem} button={true}> <ListItemAvatar> - <ArtworkAvatar record={data[id]} /> + <CoverArtAvatar record={data[id]} /> </ListItemAvatar> <ListItemText style={{ marginLeft: '8px' }} diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index 9269dc8d7..dda761097 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -3,15 +3,20 @@ import { Typography, Collapse } from '@material-ui/core' import { makeStyles } from '@material-ui/core' import Card from '@material-ui/core/Card' import CardContent from '@material-ui/core/CardContent' +import CardMedia from '@material-ui/core/CardMedia' import ArtistExternalLinks from './ArtistExternalLink' import config from '../config' -import { LoveButton, RatingField, ImageUploadOverlay } from '../common' +import { + LoveButton, + RatingField, + ImageUploadOverlay, + useImageLoadingState, +} from '../common' import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' -import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -44,6 +49,9 @@ const useStyles = makeStyles( transition: 'opacity 0.3s ease-in-out', objectFit: 'cover', }, + coverLoading: { + opacity: 0.5, + }, artistImage: { maxHeight: '12rem', minHeight: '12rem', @@ -83,18 +91,35 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles() const title = record.name - const [isLightboxOpen, setLightboxOpen] = useState(false) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useImageLoadingState(record.id) return ( <div className={classes.root}> <Card className={classes.artistDetail}> <Card className={classes.artistImage}> - <Artwork - record={record} - className={classes.cover} - title={title} - onClick={() => setLightboxOpen(true)} - /> + {artistInfo && ( + <CardMedia + key={record.id} + component="img" + src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)} + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} + onClick={handleOpenLightbox} + onLoad={handleImageLoad} + onError={handleImageError} + title={title} + style={{ + cursor: imageError ? 'default' : 'pointer', + }} + /> + )} <ImageUploadOverlay entityType="artist" entityId={record.id} @@ -150,13 +175,13 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { )} </Typography> </div> - {isLightboxOpen && ( + {isLightboxOpen && !imageError && ( <Lightbox imagePadding={50} animationDuration={200} imageTitle={record.name} mainSrc={subsonic.getCoverArtUrl(record)} - onCloseRequest={() => setLightboxOpen(false)} + onCloseRequest={handleCloseLightbox} /> )} </Card> diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 0925b6f10..e82d4c28a 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -2,12 +2,17 @@ import React, { useState } from 'react' import { Typography, Collapse } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import Card from '@material-ui/core/Card' +import CardMedia from '@material-ui/core/CardMedia' import config from '../config' -import { LoveButton, RatingField, ImageUploadOverlay } from '../common' +import { + LoveButton, + RatingField, + ImageUploadOverlay, + useImageLoadingState, +} from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' -import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -55,6 +60,9 @@ const useStyles = makeStyles( transition: 'opacity 0.3s ease-in-out', objectFit: 'cover', }, + coverLoading: { + opacity: 0.5, + }, artistImage: { marginLeft: '1em', maxHeight: '7rem', @@ -80,24 +88,41 @@ const useStyles = makeStyles( { name: 'NDMobileArtistDetails' }, ) -const MobileArtistDetails = ({ biography, record }) => { +const MobileArtistDetails = ({ artistInfo, biography, record }) => { const img = subsonic.getCoverArtUrl(record, 800) const [expanded, setExpanded] = useState(false) const classes = useStyles({ img, expanded }) const title = record.name - const [isLightboxOpen, setLightboxOpen] = useState(false) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useImageLoadingState(record.id) return ( <> <div className={classes.root}> <div className={classes.bgContainer}> <Card className={classes.artistImage}> - <Artwork - record={record} - className={classes.cover} - title={title} - onClick={() => setLightboxOpen(true)} - /> + {artistInfo && ( + <CardMedia + key={record.id} + component="img" + src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)} + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} + onClick={handleOpenLightbox} + onLoad={handleImageLoad} + onError={handleImageError} + title={title} + style={{ + cursor: imageError ? 'default' : 'pointer', + }} + /> + )} <ImageUploadOverlay entityType="artist" entityId={record.id} @@ -140,13 +165,13 @@ const MobileArtistDetails = ({ biography, record }) => { </Typography> </Collapse> </div> - {isLightboxOpen && ( + {isLightboxOpen && !imageError && ( <Lightbox imagePadding={50} animationDuration={200} imageTitle={record.name} mainSrc={img} - onCloseRequest={() => setLightboxOpen(false)} + onCloseRequest={handleCloseLightbox} /> )} </> diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index c3d795b26..e2070deea 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -272,6 +272,18 @@ 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))), @@ -424,35 +436,6 @@ 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 ( <ThemeProvider theme={createMuiTheme(theme)}> <ReactJkMusicPlayer @@ -461,6 +444,7 @@ const Player = () => { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} + onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index f93114ded..813a4f5b4 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -1,6 +1,7 @@ import { jwtDecode } from 'jwt-decode' import { baseUrl } from './utils' import config from './config' +import { removeHomeCache } from './utils/removeHomeCache' // config sent from server may contain authentication info, for example when the user is authenticated // by a reverse proxy request header @@ -48,6 +49,7 @@ const authProvider = { storeAuthenticationInfo(response) // Avoid "going to create admin" dialog after logout/login without a refresh config.firstTime = false + removeHomeCache() return response }) .catch((error) => { @@ -64,8 +66,7 @@ const authProvider = { logout: () => { removeItems() - // Only proxy-authenticated sessions go to the IdP; others (e.g. direct LAN access) get the login form - if (config.extAuthLogoutURL && config.auth) { + if (config.extAuthLogoutURL) { window.location.href = config.extAuthLogoutURL return Promise.resolve(false) } diff --git a/ui/src/authProvider.test.js b/ui/src/authProvider.test.js deleted file mode 100644 index b16a39815..000000000 --- a/ui/src/authProvider.test.js +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import config from './config' -import authProvider from './authProvider' - -vi.mock('./config', () => ({ default: {} })) - -describe('authProvider.logout', () => { - const logoutURL = 'https://auth.example.com/signout' - - beforeEach(() => { - vi.stubGlobal('location', { href: '' }) - localStorage.setItem('is-authenticated', 'true') - localStorage.setItem('token', 'abc') - }) - - afterEach(() => { - vi.unstubAllGlobals() - localStorage.clear() - delete config.extAuthLogoutURL - delete config.auth - }) - - it('clears the stored session', async () => { - await authProvider.logout() - expect(localStorage.getItem('is-authenticated')).toBeNull() - expect(localStorage.getItem('token')).toBeNull() - }) - - it('does not redirect when no logout URL is configured', async () => { - config.auth = { id: '1' } - await expect(authProvider.logout()).resolves.toBeUndefined() - expect(window.location.href).toBe('') - }) - - it('redirects to the logout URL when the page was authenticated by the proxy', async () => { - config.extAuthLogoutURL = logoutURL - config.auth = { id: '1' } - await expect(authProvider.logout()).resolves.toBe(false) - expect(window.location.href).toBe(logoutURL) - }) - - it('does not redirect when the page was not authenticated by the proxy', async () => { - config.extAuthLogoutURL = logoutURL - await expect(authProvider.logout()).resolves.toBeUndefined() - expect(window.location.href).toBe('') - }) -}) diff --git a/ui/src/common/ArtistLinkField.jsx b/ui/src/common/ArtistLinkField.jsx index 2a70ded7c..d41b47b06 100644 --- a/ui/src/common/ArtistLinkField.jsx +++ b/ui/src/common/ArtistLinkField.jsx @@ -7,8 +7,7 @@ import { intersperse } from '../utils/index.js' import { useDispatch } from 'react-redux' import { closeExtendedInfoDialog } from '../actions/dialogs.js' -// noSSR: withWidth otherwise renders null until mounted, so the artist line pops in and grows the row. -const ALink = withWidth({ noSSR: true })((props) => { +const ALink = withWidth()((props) => { const { artist, width, ...rest } = props const artistLink = useGetHandleArtistClick(width) const dispatch = useDispatch() diff --git a/ui/src/common/Artwork.jsx b/ui/src/common/Artwork.jsx deleted file mode 100644 index 21ac5e07b..000000000 --- a/ui/src/common/Artwork.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import PropTypes from 'prop-types' -import clsx from 'clsx' -import { makeStyles } from '@material-ui/core/styles' -import config from '../config' -import subsonic from '../subsonic' -import { useImageUrl } from './useImageUrl' -import { ThumbHashCanvas } from './ThumbHashCanvas' - -// Drives both the CSS transition and the timer that retires the placeholder, so they cannot drift. -const fadeMs = 150 - -const useStyles = makeStyles({ - // className supplies the size and shape; overflow:hidden clips the fills to it. - root: { - position: 'relative', - display: 'inline-flex', - overflow: 'hidden', - }, - fill: { - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - }, - img: { - opacity: 0, - transition: `opacity ${fadeMs}ms ease-out`, - '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, - }, - imgVisible: { opacity: 1 }, - // Already-decoded blobs appear at once: fading them in would re-animate on every remount. - imgInstant: { opacity: 1, transition: 'none' }, -}) - -// Renders a cover through the shared useImageUrl blob cache, so it survives remounts without -// re-fetching. The image mounts only once its blob is ready, so it never renders broken. -export const Artwork = ({ - record, - size = config.uiCoverArtSize, - square = false, - fit = 'cover', - className, - title, - onClick, -}) => { - const classes = useStyles() - const url = record ? subsonic.getCoverArtUrl(record, size, square) : '' - const { imgUrl, fromCache } = useImageUrl(url) - - const [decoded, setDecoded] = useState(false) - const [faded, setFaded] = useState(false) - const frame = useRef(0) - useEffect(() => { - setDecoded(false) - setFaded(false) - return () => cancelAnimationFrame(frame.current) - }, [url]) - - // Two frames: a blob decodes from memory, so onLoad can beat the first paint of opacity 0, - // leaving the transition no start value and popping some tiles in while others fade. - const handleLoad = () => { - frame.current = requestAnimationFrame(() => { - frame.current = requestAnimationFrame(() => setDecoded(true)) - }) - } - - // Timer, not transitionend: under prefers-reduced-motion there is no transition to end. - useEffect(() => { - if (!decoded || faded) return undefined - const timer = setTimeout(() => setFaded(true), fadeMs) - return () => clearTimeout(timer) - }, [decoded, faded]) - - if (!record) return null - - // A blob already cached paints on the first frame, so it skips the fade. - const instant = fromCache - // Kept mounted until the fade ends; swapping on blob arrival would flash an empty container. - const showPlaceholder = !!record.thumbHash && !instant && !faded - // A square request is padded, not cropped, so `contain` keeps placeholder and image aligned. - const effectiveFit = square ? 'contain' : fit - const ratio = record.imageWidth / record.imageHeight - const handleClick = imgUrl && onClick ? onClick : undefined - return ( - <div - className={clsx(classes.root, className)} - onClick={handleClick} - style={{ cursor: handleClick ? 'pointer' : 'default' }} - > - {showPlaceholder && ( - <ThumbHashCanvas - hash={record.thumbHash} - ratio={ratio} - fit={effectiveFit} - className={classes.fill} - /> - )} - {imgUrl && ( - <img - src={imgUrl} - alt={title} - title={title} - className={clsx( - classes.fill, - classes.img, - instant && classes.imgInstant, - decoded && classes.imgVisible, - )} - style={{ objectFit: effectiveFit }} - onLoad={handleLoad} - /> - )} - </div> - ) -} - -Artwork.propTypes = { - record: PropTypes.object, - size: PropTypes.number, - square: PropTypes.bool, - fit: PropTypes.oneOf(['cover', 'contain']), - className: PropTypes.string, - title: PropTypes.string, - onClick: PropTypes.func, -} diff --git a/ui/src/common/Artwork.test.jsx b/ui/src/common/Artwork.test.jsx deleted file mode 100644 index 18b3c916f..000000000 --- a/ui/src/common/Artwork.test.jsx +++ /dev/null @@ -1,232 +0,0 @@ -import { render, fireEvent, act } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('./useImageUrl', () => ({ useImageUrl: vi.fn() })) -vi.mock('../subsonic', () => ({ - default: { - // Mirrors the real hash suffix, so a refreshed record yields a different URL. - getCoverArtUrl: (record) => - '/rest/getCoverArt?id=al-1' + - (record.imageHash ? `_${record.imageHash}` : ''), - }, -})) -vi.mock('../config', () => ({ default: { uiCoverArtSize: 300 } })) - -import { useImageUrl } from './useImageUrl' -import { Artwork } from './Artwork' - -const withArt = { - id: 'al-1', - name: 'Album', - thumbHash: 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I', -} - -describe('Artwork', () => { - beforeEach(() => { - vi.clearAllMocks() - // jsdom has no 2D context; stub it so ThumbHashCanvas bails cleanly without console noise - HTMLCanvasElement.prototype.getContext = vi.fn(() => null) - }) - - it('renders nothing without a record', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: false }) - const { container } = render(<Artwork record={null} />) - expect(container.firstChild).toBeNull() - }) - - it('shows the placeholder and no <img> while loading', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container } = render(<Artwork record={withArt} title="Album" />) - expect(container.querySelector('canvas')).not.toBeNull() - expect(container.querySelector('img')).toBeNull() - }) - - it('shows neither a broken <img> nor a canvas while loading a record with no thumbhash', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container } = render(<Artwork record={{ id: 'al-2', name: 'X' }} />) - expect(container.querySelector('img')).toBeNull() - expect(container.querySelector('canvas')).toBeNull() - }) - - // The placeholder has to land exactly where the image will, or it jumps when the image swaps in. - it('shapes the placeholder like the artwork and fits it like the image', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const nonSquare = { ...withArt, imageWidth: 1200, imageHeight: 800 } - const { container } = render( - <Artwork record={nonSquare} fit="contain" title="Album" />, - ) - const canvas = container.querySelector('canvas') - expect(canvas.width).toBe(32) - expect(canvas.height).toBe(21) - expect(canvas.style.objectFit).toBe('contain') - }) - - // A square request is padded, not cropped, so the placeholder has to letterbox with it. - it('letterboxes the placeholder when the server pads a non-square image to a square', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const nonSquare = { ...withArt, imageWidth: 1200, imageHeight: 800 } - const { container } = render(<Artwork record={nonSquare} square />) - const canvas = container.querySelector('canvas') - expect(canvas.width).toBe(32) - expect(canvas.height).toBe(21) - expect(canvas.style.objectFit).toBe('contain') - }) - - // The padded square is aspect-fit, so `square` overrides fit="cover" as it does for the canvas. - it('fits the image itself with contain when the server padded to a square', () => { - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: true, - }) - const { container } = render( - <Artwork record={withArt} square fit="cover" title="Album" />, - ) - expect(container.querySelector('img').style.objectFit).toBe('contain') - }) - - it('fills the box for square artwork, the overwhelmingly common case', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const sq = { ...withArt, imageWidth: 600, imageHeight: 600 } - const { container } = render(<Artwork record={sq} square />) - const canvas = container.querySelector('canvas') - expect(canvas.width).toBe(32) - expect(canvas.height).toBe(32) - }) - - // Without dimensions the placeholder falls back to the aspect the hash itself carries. - it('falls back to the hash own aspect when the record has no dimensions', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container } = render(<Artwork record={withArt} />) - const canvas = container.querySelector('canvas') - expect(canvas.width).toBe(32) - expect(canvas.height).toBe(32) - }) - - it('mounts the image only once its blob is ready', () => { - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: true, - }) - const { container } = render(<Artwork record={withArt} title="Album" />) - const img = container.querySelector('img') - expect(img).not.toBeNull() - expect(img.getAttribute('src')).toBe('blob:abc') - }) - - it('keeps the placeholder under the image until the fade ends', () => { - vi.useFakeTimers() - try { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container, rerender } = render(<Artwork record={withArt} />) - - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: false, - }) - rerender(<Artwork record={withArt} />) - const img = container.querySelector('img') - expect(img).not.toBeNull() - expect(container.querySelector('canvas')).not.toBeNull() - - // The clock ends the fade, not transitionend, which never fires under reduced-motion. - act(() => { - fireEvent.load(img) - }) - expect(container.querySelector('canvas')).not.toBeNull() - act(() => { - fireEvent.transitionEnd(img) - }) - expect(container.querySelector('canvas')).not.toBeNull() - // Two frames pass before the fade starts, so opacity 0 gets painted first. - act(() => { - vi.advanceTimersByTime(32) - }) - expect(container.querySelector('canvas')).not.toBeNull() - act(() => { - vi.advanceTimersByTime(150) - }) - expect(container.querySelector('canvas')).toBeNull() - } finally { - vi.useRealTimers() - } - }) - - it('does not fade an image that was already cached on mount', () => { - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: true, - }) - const { container } = render(<Artwork record={withArt} />) - expect(container.querySelector('canvas')).toBeNull() - expect(container.querySelector('img').className).toContain('imgInstant') - }) - - it('keeps the placeholder visible when the image never decodes', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container, rerender } = render(<Artwork record={withArt} />) - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: false, - }) - rerender(<Artwork record={withArt} />) - - expect(container.querySelector('canvas')).not.toBeNull() - }) - - it('fires onClick only when the image is loaded', () => { - const onClick = vi.fn() - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container, rerender } = render( - <Artwork record={withArt} onClick={onClick} />, - ) - container.firstChild.click() - expect(onClick).not.toHaveBeenCalled() - - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: false, - }) - rerender(<Artwork record={withArt} onClick={onClick} />) - container.firstChild.click() - expect(onClick).toHaveBeenCalledTimes(1) - }) - - it('shows the placeholder for a cached fetch failure, which has no blob', () => { - useImageUrl.mockReturnValue({ - imgUrl: null, - loading: false, - error: true, - fromCache: false, - }) - const { container } = render(<Artwork record={withArt} />) - expect(container.querySelector('img')).toBeNull() - expect(container.querySelector('canvas')).not.toBeNull() - }) - - it('falls back to the placeholder when a refresh swaps in an uncached hash', () => { - useImageUrl.mockReturnValue({ - imgUrl: 'blob:old', - loading: false, - fromCache: true, - }) - const cached = { ...withArt, imageHash: 'aaaa' } - const { container, rerender } = render(<Artwork record={cached} />) - expect(container.querySelector('canvas')).toBeNull() - - useImageUrl.mockReturnValue({ - imgUrl: null, - loading: true, - fromCache: false, - }) - rerender(<Artwork record={{ ...withArt, imageHash: 'bbbb' }} />) - - expect(container.querySelector('img')).toBeNull() - expect(container.querySelector('canvas')).not.toBeNull() - }) -}) diff --git a/ui/src/common/ArtworkAvatar.jsx b/ui/src/common/ArtworkAvatar.jsx deleted file mode 100644 index 7691050c6..000000000 --- a/ui/src/common/ArtworkAvatar.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useRecordContext } from 'react-admin' -import { makeStyles } from '@material-ui/core/styles' -import clsx from 'clsx' -import config from '../config' -import { Artwork } from './Artwork' - -const useStyles = makeStyles({ - avatar: { - width: '55px', - height: '55px', - }, - square: { - borderRadius: '4px', - }, - circular: { - borderRadius: '50%', - }, -}) - -export const ArtworkAvatar = ({ record: recordProp, variant = 'circular' }) => { - const classes = useStyles() - const recordContext = useRecordContext() - const record = recordProp || recordContext - if (!record) return null - const square = variant !== 'circular' - return ( - <Artwork - record={record} - size={config.uiCoverArtSize} - square={square} - className={clsx( - classes.avatar, - square ? classes.square : classes.circular, - )} - title={record.name} - /> - ) -} - -ArtworkAvatar.defaultProps = { label: '', sortable: false } diff --git a/ui/src/common/ContextMenus.jsx b/ui/src/common/ContextMenus.jsx index 4407392c1..47c9c6786 100644 --- a/ui/src/common/ContextMenus.jsx +++ b/ui/src/common/ContextMenus.jsx @@ -7,12 +7,7 @@ import MenuItem from '@material-ui/core/MenuItem' import MoreVertIcon from '@material-ui/icons/MoreVert' import { MdQuestionMark } from 'react-icons/md' import { makeStyles } from '@material-ui/core/styles' -import { - useDataProvider, - useNotify, - usePermissions, - useTranslate, -} from 'react-admin' +import { useDataProvider, useNotify, useTranslate } from 'react-admin' import clsx from 'clsx' import { playNext, @@ -29,8 +24,6 @@ import { import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' -import { artistDownloadSize } from './artist' -import { useRefreshMetadata } from './useRefreshMetadata' const useStyles = makeStyles({ noWrap: { @@ -75,13 +68,8 @@ const ContextMenu = ({ const dispatch = useDispatch() const translate = useTranslate() const notify = useNotify() - const { permissions } = usePermissions() - const refreshMetadata = useRefreshMetadata() const [anchorEl, setAnchorEl] = useState(null) - const isArtist = resource === 'artist' - const downloadSize = isArtist ? artistDownloadSize(record) : record?.size - const options = { play: { enabled: true, @@ -115,7 +103,7 @@ const ContextMenu = ({ }, ...(!hideShare && { share: { - enabled: config.enableSharing && (!isArtist || downloadSize), + enabled: config.enableSharing, needData: false, label: translate('ra.action.share'), action: (record) => @@ -123,9 +111,9 @@ const ContextMenu = ({ }, }), download: { - enabled: config.enableDownloads && downloadSize, + enabled: config.enableDownloads && record.size, needData: false, - label: `${translate('ra.action.download')} (${formatBytes(downloadSize)})`, + label: `${translate('ra.action.download')} (${formatBytes(record.size)})`, action: () => { dispatch( openDownloadMenu( @@ -137,12 +125,6 @@ const ContextMenu = ({ ) }, }, - refresh: { - enabled: permissions === 'admin', - needData: false, - label: translate('resources.album.actions.refresh'), - action: (record) => refreshMetadata(resource, record.id), - }, ...(!hideInfo && { info: { enabled: true, diff --git a/ui/src/common/ContextMenus.test.jsx b/ui/src/common/ContextMenus.test.jsx deleted file mode 100644 index 59a7de966..000000000 --- a/ui/src/common/ContextMenus.test.jsx +++ /dev/null @@ -1,133 +0,0 @@ -import React from 'react' -import { render, fireEvent, screen } from '@testing-library/react' -import { TestContext } from 'ra-test' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' -import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus' - -const mockDispatch = vi.fn() -vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch })) - -const { mockConfig } = vi.hoisted(() => ({ - mockConfig: { - enableSharing: true, - enableDownloads: true, - enableFavourites: false, - }, -})) -vi.mock('../config', () => ({ default: mockConfig })) - -const { mockPermissions, mockRefreshMetadata } = vi.hoisted(() => ({ - mockPermissions: { value: 'admin' }, - mockRefreshMetadata: vi.fn(), -})) - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useNotify: () => vi.fn(), - usePermissions: () => ({ permissions: mockPermissions.value }), - useDataProvider: () => ({ - getList: vi.fn(), - refreshMetadata: mockRefreshMetadata, - }), - useTranslate: () => (x) => x, - } -}) - -describe('ContextMenus', () => { - const renderMenu = (Menu, record) => { - render( - <TestContext> - <ThemeProvider theme={createTheme()}> - <Menu record={record} /> - </ThemeProvider> - </TestContext>, - ) - fireEvent.click(screen.getByLabelText('more')) - } - - beforeEach(() => { - vi.clearAllMocks() - mockConfig.enableSharing = true - mockConfig.enableDownloads = true - mockPermissions.value = 'admin' - }) - - describe('ArtistContextMenu', () => { - const withAlbumArtist = { - id: 'ar1', - name: 'Artist', - stats: { albumartist: { songCount: 3, albumCount: 1, size: 1024 } }, - } - - it('shows the album-artist size on the download item', () => { - renderMenu(ArtistContextMenu, withAlbumArtist) - expect(screen.getByText('ra.action.download (1 KB)')).toBeInTheDocument() - }) - - it('hides download and share for artists with no album-artist content', () => { - renderMenu(ArtistContextMenu, { id: 'ar1', name: 'Artist', stats: {} }) - expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() - expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() - }) - }) - - describe('AlbumContextMenu', () => { - it('uses the total size on the album download item', () => { - renderMenu(AlbumContextMenu, { - id: 'al1', - name: 'Album', - duration: 100, - size: 1024 * 1024, - }) - expect(screen.getByText('ra.action.download (1 MB)')).toBeInTheDocument() - }) - }) - - describe('refresh metadata', () => { - it('shows the item for admins on the album menu', () => { - renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) - expect( - screen.getByText('resources.album.actions.refresh'), - ).toBeInTheDocument() - }) - - // Menu order comes from key insertion order in the options object, so it is easy to - // change by accident when adding an entry. - it('places the item directly above Get Info', () => { - renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) - const labels = screen - .getAllByRole('menuitem') - .map((item) => item.textContent) - const refreshAt = labels.indexOf('resources.album.actions.refresh') - const infoAt = labels.indexOf('resources.album.actions.info') - - expect(refreshAt).toBeGreaterThanOrEqual(0) - expect(infoAt).toEqual(refreshAt + 1) - }) - - it('shows the item for admins on the artist menu', () => { - renderMenu(ArtistContextMenu, { id: 'ar1', name: 'Artist', stats: {} }) - expect( - screen.getByText('resources.album.actions.refresh'), - ).toBeInTheDocument() - }) - - it('hides the item for regular users', () => { - mockPermissions.value = 'regular' - renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) - expect( - screen.queryByText('resources.album.actions.refresh'), - ).not.toBeInTheDocument() - }) - - it('calls refreshMetadata with the resource and id', () => { - mockRefreshMetadata.mockResolvedValue({}) - renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) - fireEvent.click(screen.getByText('resources.album.actions.refresh')) - expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1') - }) - }) -}) diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx new file mode 100644 index 000000000..f70403774 --- /dev/null +++ b/ui/src/common/CoverArtAvatar.jsx @@ -0,0 +1,52 @@ +import { useRecordContext } from 'react-admin' +import { Avatar } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import clsx from 'clsx' +import config from '../config' +import subsonic from '../subsonic' +import { useImageUrl } from './useImageUrl' + +const useStyles = makeStyles({ + avatar: { + width: '55px', + height: '55px', + }, + avatarEmpty: { + backgroundColor: 'transparent', + }, + square: { + borderRadius: '4px', + }, +}) + +export const CoverArtAvatar = ({ + record: recordProp, + variant = 'circular', +}) => { + const classes = useStyles() + const recordContext = useRecordContext() + const record = recordProp || recordContext + const square = variant !== 'circular' + const url = record + ? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square) + : null + const { imgUrl } = useImageUrl(url) + if (!record) return null + return ( + <Avatar + src={imgUrl || undefined} + variant={variant} + className={clsx( + classes.avatar, + square && classes.square, + !imgUrl && classes.avatarEmpty, + )} + alt={record.name} + > + {/* Empty child prevents default person icon while loading */} + {!imgUrl && <span />} + </Avatar> + ) +} + +CoverArtAvatar.defaultProps = { label: '', sortable: false } diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx index dac9cff08..dce24a2b9 100644 --- a/ui/src/common/DateField.jsx +++ b/ui/src/common/DateField.jsx @@ -1,14 +1,12 @@ import React from 'react' import { isDateSet } from '../utils/validations' import { DateField as RADateField } from 'react-admin' -import { useDateLocale } from '../i18n/useDateLocale' export const DateField = (props) => { const { record, source } = props - const locale = useDateLocale() const value = record?.[source] if (!isDateSet(value)) return null - return <RADateField locales={locale} {...props} /> + return <RADateField {...props} /> } DateField.defaultProps = { diff --git a/ui/src/common/DateField.test.jsx b/ui/src/common/DateField.test.jsx deleted file mode 100644 index 47d56756b..000000000 --- a/ui/src/common/DateField.test.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { DateField } from './DateField' - -vi.mock('react-admin', async (importOriginal) => ({ - ...(await importOriginal()), - useLocale: vi.fn(), -})) - -describe('<DateField>', () => { - const record = { id: '1', updatedAt: '2026-09-17T14:30:00Z' } - - beforeEach(async () => { - vi.clearAllMocks() - vi.spyOn(navigator, 'languages', 'get').mockReturnValue([]) - const { useLocale } = await import('react-admin') - vi.mocked(useLocale).mockReturnValue('de') - }) - - it('formats the date using the selected language', () => { - render(<DateField record={record} source="updatedAt" />) - expect(screen.getByText('17.9.2026')).toBeInTheDocument() - }) - - it('renders nothing when the date is not set', () => { - const { container } = render( - <DateField record={{ id: '1' }} source="updatedAt" />, - ) - expect(container).toBeEmptyDOMElement() - }) -}) diff --git a/ui/src/common/List.jsx b/ui/src/common/List.jsx index 0ae089460..72c2d9482 100644 --- a/ui/src/common/List.jsx +++ b/ui/src/common/List.jsx @@ -2,7 +2,6 @@ import React from 'react' import { List as RAList } from 'react-admin' import config from '../config' import { Pagination } from './Pagination' -import { defaultRowsPerPageOptions, getStoredPerPage } from './perPageStore' import { Title } from './index' export const List = (props) => { @@ -16,7 +15,7 @@ export const List = (props) => { /> } debounce={config.uiSearchDebounceMs} - perPage={getStoredPerPage(resource, defaultRowsPerPageOptions)} + perPage={15} pagination={<Pagination />} {...props} /> diff --git a/ui/src/common/List.test.jsx b/ui/src/common/List.test.jsx deleted file mode 100644 index 5bc4b910f..000000000 --- a/ui/src/common/List.test.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' -import { List } from './List' - -// Only stub the heavy react-admin List controller (data fetching, router sync); -// everything else, including our own Pagination/perPageStore wiring, stays real -// so a bad import (the bug this test guards against) throws on render. -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - List: ({ children }) => <div data-testid="ra-list">{children}</div>, - } -}) - -describe('List', () => { - it('renders without throwing and shows its children', () => { - render( - <List resource="song"> - <div>list content</div> - </List>, - ) - expect(screen.getByTestId('ra-list')).toBeInTheDocument() - expect(screen.getByText('list content')).toBeInTheDocument() - }) -}) diff --git a/ui/src/common/LoveButton.jsx b/ui/src/common/LoveButton.jsx index 128b375c2..492ba95b3 100644 --- a/ui/src/common/LoveButton.jsx +++ b/ui/src/common/LoveButton.jsx @@ -6,8 +6,6 @@ import IconButton from '@material-ui/core/IconButton' import { makeStyles } from '@material-ui/core/styles' import clsx from 'clsx' import { useToggleLove } from './useToggleLove' -import { useDateLocale } from '../i18n/useDateLocale' -import { formatDateTime } from '../utils/formatters' import { useRecordContext } from 'react-admin' import config from '../config' import { isDateSet } from '../utils/validations' @@ -42,7 +40,6 @@ export const LoveButton = ({ const record = useRecordContext({ record: recordProp }) || {} const classes = useStyles({ color, visible, loved: record.starred }) const [toggleLove, loading] = useToggleLove(resource, record) - const locale = useDateLocale() const handleToggleLove = useCallback( (e) => { @@ -64,7 +61,7 @@ export const LoveButton = ({ className={clsx(classes.love, className)} title={ isDateSet(record.starredAt) - ? formatDateTime(record.starredAt, locale) + ? new Date(record.starredAt).toLocaleString() : undefined } {...rest} diff --git a/ui/src/common/Pagination.jsx b/ui/src/common/Pagination.jsx index dd18961ce..e17d9e63e 100644 --- a/ui/src/common/Pagination.jsx +++ b/ui/src/common/Pagination.jsx @@ -1,29 +1,6 @@ -import React, { useCallback } from 'react' -import { - Pagination as RAPagination, - useListPaginationContext, -} from 'react-admin' -import { setStoredPerPage, defaultRowsPerPageOptions } from './perPageStore' +import React from 'react' +import { Pagination as RAPagination } from 'react-admin' -export const Pagination = ({ - rowsPerPageOptions = defaultRowsPerPageOptions, - ...props -}) => { - const { resource, setPerPage } = useListPaginationContext() - // Persist only a selector-driven change: mount, URL params and responsive - // fallbacks never call setPerPage, so they can't overwrite the preference. - const handleSetPerPage = useCallback( - (value) => { - if (resource) setStoredPerPage(resource, value) - setPerPage(value) - }, - [resource, setPerPage], - ) - return ( - <RAPagination - rowsPerPageOptions={rowsPerPageOptions} - {...props} - setPerPage={handleSetPerPage} - /> - ) -} +export const Pagination = (props) => ( + <RAPagination rowsPerPageOptions={[15, 25, 50]} {...props} /> +) diff --git a/ui/src/common/Pagination.test.jsx b/ui/src/common/Pagination.test.jsx deleted file mode 100644 index a488aba91..000000000 --- a/ui/src/common/Pagination.test.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { Pagination } from './Pagination' - -// stub RA's Pagination so a test can invoke the injected setPerPage, i.e. -// simulate an actual rows-per-page selection -vi.mock('react-admin', async () => { - const React = await vi.importActual('react') - return { - Pagination: ({ setPerPage }) => - React.createElement( - 'button', - { onClick: () => setPerPage(50) }, - 'select 50', - ), - useListPaginationContext: vi.fn(), - } -}) - -describe('Pagination', () => { - let mockContext - let setPerPage - - beforeEach(async () => { - vi.clearAllMocks() - localStorage.clear() - setPerPage = vi.fn() - const { useListPaginationContext } = await import('react-admin') - mockContext = vi.mocked(useListPaginationContext) - }) - - const selectPerPage = () => fireEvent.click(screen.getByText('select 50')) - - it('persists the page size chosen in the selector', () => { - mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) - render(<Pagination />) - selectPerPage() - expect(localStorage.getItem('perPage.song')).toEqual('50') - }) - - it('still applies the change to the list', () => { - mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) - render(<Pagination />) - selectPerPage() - expect(setPerPage).toHaveBeenCalledWith(50) - }) - - it('does not persist a page size the user did not select', () => { - mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) - render(<Pagination />) - expect(localStorage.getItem('perPage.song')).toBeNull() - }) - - it('does not persist without a resource in context', () => { - mockContext.mockReturnValue({ perPage: 15, setPerPage }) - render(<Pagination />) - selectPerPage() - expect(localStorage.getItem('perPage.undefined')).toBeNull() - expect(setPerPage).toHaveBeenCalledWith(50) - }) -}) diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.jsx index f892b475a..f92b0d948 100644 --- a/ui/src/common/RatingField.jsx +++ b/ui/src/common/RatingField.jsx @@ -6,8 +6,6 @@ import { isDateSet } from '../utils/validations' import StarBorderIcon from '@material-ui/icons/StarBorder' import clsx from 'clsx' import { useRating } from './useRating' -import { useDateLocale } from '../i18n/useDateLocale' -import { formatDateTime } from '../utils/formatters' import { useRecordContext } from 'react-admin' const useStyles = makeStyles({ @@ -34,7 +32,6 @@ export const RatingField = ({ const record = useRecordContext(rest) || {} const [rate, rating] = useRating(resource, record) const classes = useStyles({ color, visible }) - const locale = useDateLocale() const stopPropagation = (e) => { e.stopPropagation() @@ -53,7 +50,7 @@ export const RatingField = ({ onClick={(e) => stopPropagation(e)} title={ isDateSet(record.ratedAt) - ? formatDateTime(record.ratedAt, locale) + ? new Date(record.ratedAt).toLocaleString() : undefined } > diff --git a/ui/src/common/ReadOnlyFields.jsx b/ui/src/common/ReadOnlyFields.jsx deleted file mode 100644 index c56643fbd..000000000 --- a/ui/src/common/ReadOnlyFields.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import get from 'lodash/get' -import { FieldTitle, useRecordContext } from 'react-admin' -import { TextField } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import { useDateLocale } from '../i18n/useDateLocale' -import { - formatBytes, - formatDateTime, - formatDuration2, - formatNumber, -} from '../utils/formatters' -import { isDateSet } from '../utils/validations' - -const useStyles = makeStyles( - (theme) => ({ - inputRoot: { - '&:hover $notchedOutline': { - borderColor: theme.palette.divider, - }, - }, - notchedOutline: { - borderColor: theme.palette.divider, - }, - }), - { name: 'NDReadOnlyField' }, -) - -const identity = (v) => v - -// Renders a record value as a dimmed, non-editable input, so it lines up with the inputs in a form -export const ReadOnlyTextField = ({ - source, - label, - resource, - className, - fullWidth, - format = identity, - ...props -}) => { - const classes = useStyles(props) - const record = useRecordContext(props) - const value = get(record, source) - - return ( - <TextField - id={source} - className={className} - label={<FieldTitle label={label} source={source} resource={resource} />} - value={value == null ? '' : format(value)} - variant="outlined" - margin="dense" - fullWidth={fullWidth} - focused={false} - helperText=" " - InputProps={{ - readOnly: true, - classes: { - root: classes.inputRoot, - notchedOutline: classes.notchedOutline, - }, - }} - inputProps={{ tabIndex: -1 }} - /> - ) -} - -ReadOnlyTextField.propTypes = { - source: PropTypes.string.isRequired, - label: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - record: PropTypes.object, - resource: PropTypes.string, - className: PropTypes.string, - classes: PropTypes.object, - fullWidth: PropTypes.bool, - format: PropTypes.func, -} - -export const ReadOnlyDateField = (props) => { - const locale = useDateLocale() - const format = (v) => (isDateSet(v) ? formatDateTime(v, locale) : '') - return <ReadOnlyTextField format={format} {...props} /> -} - -export const ReadOnlyNumberField = (props) => { - const locale = useDateLocale() - return ( - <ReadOnlyTextField format={(v) => formatNumber(v, locale)} {...props} /> - ) -} - -export const ReadOnlySizeField = (props) => ( - <ReadOnlyTextField format={formatBytes} {...props} /> -) - -export const ReadOnlyDurationField = (props) => ( - <ReadOnlyTextField format={formatDuration2} {...props} /> -) diff --git a/ui/src/common/ReadOnlyFields.test.jsx b/ui/src/common/ReadOnlyFields.test.jsx deleted file mode 100644 index 00543a786..000000000 --- a/ui/src/common/ReadOnlyFields.test.jsx +++ /dev/null @@ -1,121 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { - ReadOnlyDateField, - ReadOnlyDurationField, - ReadOnlyNumberField, - ReadOnlySizeField, - ReadOnlyTextField, -} from './ReadOnlyFields' - -vi.mock('react-admin', async (importOriginal) => ({ - ...(await importOriginal()), - useLocale: vi.fn(), -})) - -describe('ReadOnlyFields', () => { - const record = { - id: '1', - client: 'NavidromeUI', - createdAt: '2026-09-17T14:30:00Z', - lastVisitedAt: '0001-01-01T00:00:00Z', - count: 1234567, - zero: 0, - size: 1536000, - duration: 3725, - } - - beforeEach(async () => { - vi.clearAllMocks() - vi.spyOn(navigator, 'languages', 'get').mockReturnValue([]) - const { useLocale } = await import('react-admin') - vi.mocked(useLocale).mockReturnValue('de') - }) - - const renderField = (Field, props) => - render(<Field record={record} resource="player" {...props} />) - - describe('<ReadOnlyTextField>', () => { - it('shows the record value with the translated field label', () => { - renderField(ReadOnlyTextField, { source: 'client' }) - const input = screen.getByLabelText('resources.player.fields.client') - expect(input).toHaveValue('NavidromeUI') - }) - - it('cannot be edited or reached with the Tab key', () => { - renderField(ReadOnlyTextField, { source: 'client' }) - const input = screen.getByRole('textbox') - expect(input).toHaveAttribute('readonly') - expect(input).toHaveAttribute('tabindex', '-1') - }) - - it('shows an empty value when the record has no value', () => { - renderField(ReadOnlyTextField, { source: 'userName' }) - expect(screen.getByRole('textbox')).toHaveValue('') - }) - - it('uses an explicit label when given', () => { - renderField(ReadOnlyTextField, { source: 'client', label: 'Custom' }) - expect(screen.getByLabelText('Custom')).toBeInTheDocument() - }) - - it('applies a custom format', () => { - renderField(ReadOnlyTextField, { - source: 'client', - format: (v) => v.toUpperCase(), - }) - expect(screen.getByRole('textbox')).toHaveValue('NAVIDROMEUI') - }) - - it('exposes theme-overridable class names', () => { - const { container } = renderField(ReadOnlyTextField, { - source: 'client', - }) - expect( - container.querySelector('[class*="NDReadOnlyField-inputRoot"]'), - ).toBeInTheDocument() - expect( - container.querySelector('[class*="NDReadOnlyField-notchedOutline"]'), - ).toBeInTheDocument() - }) - }) - - describe('<ReadOnlyDateField>', () => { - it('formats the date using the selected language', () => { - renderField(ReadOnlyDateField, { source: 'createdAt' }) - expect(screen.getByRole('textbox').value).toMatch(/^17\.9\.2026, /) - }) - - it('shows an empty value when the date is not set', () => { - renderField(ReadOnlyDateField, { source: 'lastVisitedAt' }) - expect(screen.getByRole('textbox')).toHaveValue('') - }) - }) - - describe('<ReadOnlyNumberField>', () => { - it('formats the number using the selected language', () => { - renderField(ReadOnlyNumberField, { source: 'count' }) - expect(screen.getByRole('textbox')).toHaveValue('1.234.567') - }) - - it('shows zero', () => { - renderField(ReadOnlyNumberField, { source: 'zero' }) - expect(screen.getByRole('textbox')).toHaveValue('0') - }) - }) - - describe('<ReadOnlySizeField>', () => { - it('formats bytes as a human-readable size', () => { - renderField(ReadOnlySizeField, { source: 'size' }) - expect(screen.getByRole('textbox')).toHaveValue('1.46 MB') - }) - }) - - describe('<ReadOnlyDurationField>', () => { - it('formats seconds as a human-readable duration', () => { - renderField(ReadOnlyDurationField, { source: 'duration' }) - expect(screen.getByRole('textbox')).toHaveValue('1h 2m 5s') - }) - }) -}) diff --git a/ui/src/common/RefreshMetadataButton.jsx b/ui/src/common/RefreshMetadataButton.jsx deleted file mode 100644 index aa0533ade..000000000 --- a/ui/src/common/RefreshMetadataButton.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { useCallback } from 'react' -import PropTypes from 'prop-types' -import Tooltip from '@material-ui/core/Tooltip' -import { makeStyles } from '@material-ui/core/styles' -import { Button, usePermissions, useTranslate } from 'react-admin' -import { MdRefresh } from 'react-icons/md' -import { useRefreshMetadata } from './useRefreshMetadata' - -const useStyles = makeStyles({ - // Tooltip needs a ref-holding child, and react-admin's Button does not forward one. - wrapper: { display: 'inline-flex', verticalAlign: 'middle' }, - button: { minWidth: 'auto' }, -}) - -// react-admin's Button, not an IconButton: the toolbars use it, so colour and the icon-only swap -// at xs match without restating either rule. -export const RefreshMetadataButton = ({ - resource, - record, - className, - size, -}) => { - const translate = useTranslate() - const { permissions } = usePermissions() - const refreshMetadata = useRefreshMetadata() - const classes = useStyles() - - const handleClick = useCallback( - () => refreshMetadata(resource, record?.id), - [refreshMetadata, resource, record], - ) - - if (permissions !== 'admin' || !record?.id) return null - - const label = translate('resources.album.actions.refresh') - return ( - <Tooltip title={label}> - <span className={classes.wrapper}> - <Button - aria-label={label} - className={className} - classes={{ button: classes.button }} - size={size} - onClick={handleClick} - > - <MdRefresh /> - </Button> - </span> - </Tooltip> - ) -} - -RefreshMetadataButton.propTypes = { - resource: PropTypes.oneOf(['album', 'artist']).isRequired, - record: PropTypes.object, - className: PropTypes.string, - size: PropTypes.oneOf(['small', 'medium']), -} - -RefreshMetadataButton.defaultProps = { - size: 'small', -} diff --git a/ui/src/common/RefreshMetadataButton.test.jsx b/ui/src/common/RefreshMetadataButton.test.jsx deleted file mode 100644 index 7bda0497c..000000000 --- a/ui/src/common/RefreshMetadataButton.test.jsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { RefreshMetadataButton } from './RefreshMetadataButton' - -const mockNotify = vi.fn() -const mockRefreshMetadata = vi.fn() -const { mockPermissions } = vi.hoisted(() => ({ - mockPermissions: { value: 'admin' }, -})) - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useNotify: () => mockNotify, - useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), - usePermissions: () => ({ permissions: mockPermissions.value }), - useTranslate: () => (x) => x, - } -}) - -describe('RefreshMetadataButton', () => { - const record = { id: 'al-1', name: 'Album' } - - beforeEach(() => { - vi.clearAllMocks() - mockPermissions.value = 'admin' - mockRefreshMetadata.mockResolvedValue({ data: { id: 'al-1' } }) - }) - - const renderButton = (props = {}) => - render( - <ThemeProvider theme={createTheme()}> - <RefreshMetadataButton resource="album" record={record} {...props} /> - </ThemeProvider>, - ) - - it('renders an icon-only button labelled by the refresh action', () => { - renderButton() - const button = screen.getByRole('button', { - name: 'resources.album.actions.refresh', - }) - expect(button).toBeInTheDocument() - expect(button).toHaveTextContent('') - }) - - it('shows the label as a tooltip on hover', async () => { - renderButton() - fireEvent.mouseOver( - screen.getByRole('button', { - name: 'resources.album.actions.refresh', - }), - ) - await waitFor(() => - expect(screen.getByRole('tooltip')).toHaveTextContent( - 'resources.album.actions.refresh', - ), - ) - }) - - it('renders nothing for non-admin users', () => { - mockPermissions.value = 'regular' - const { container } = renderButton() - expect(container).toBeEmptyDOMElement() - }) - - it('renders nothing without a record', () => { - const { container } = renderButton({ record: undefined }) - expect(container).toBeEmptyDOMElement() - }) - - it('requests a refresh for the record and notifies success', async () => { - renderButton() - fireEvent.click(screen.getByRole('button')) - - await waitFor(() => - expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al-1'), - ) - await waitFor(() => - expect(mockNotify).toHaveBeenCalledWith('message.metadataRefreshStarted'), - ) - }) - - it('passes the artist resource through', async () => { - renderButton({ resource: 'artist', record: { id: 'ar-1' } }) - fireEvent.click(screen.getByRole('button')) - - await waitFor(() => - expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar-1'), - ) - }) - - it('notifies a warning when the request fails', async () => { - mockRefreshMetadata.mockRejectedValue(new Error('boom')) - renderButton() - fireEvent.click(screen.getByRole('button')) - - await waitFor(() => - expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning'), - ) - }) -}) diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx index 4a813f84d..d2c98bbe7 100644 --- a/ui/src/common/SongDatagrid.jsx +++ b/ui/src/common/SongDatagrid.jsx @@ -78,7 +78,7 @@ const useStyles = makeStyles({ }, }) -export const DiscSubtitleRow = forwardRef( +const DiscSubtitleRow = forwardRef( ({ record, onClick, colSpan, contextAlwaysVisible }, ref) => { const translate = useTranslate() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) @@ -153,17 +153,13 @@ export const DiscSubtitleRow = forwardRef( {subtitle} </Typography> {isLightboxOpen && !imageError && ( - // Lightbox portals out of the row, but React still bubbles its - // events up this tree, where the row's onClick would play the disc. - <span onClick={(e) => e.stopPropagation()}> - <Lightbox - imagePadding={50} - animationDuration={200} - imageTitle={record.album + ' - ' + subtitle} - mainSrc={fullImageUrl} - onCloseRequest={handleCloseLightbox} - /> - </span> + <Lightbox + imagePadding={50} + animationDuration={200} + imageTitle={record.album + ' - ' + subtitle} + mainSrc={fullImageUrl} + onCloseRequest={handleCloseLightbox} + /> )} </TableCell> <TableCell> diff --git a/ui/src/common/SongDatagrid.test.jsx b/ui/src/common/SongDatagrid.test.jsx deleted file mode 100644 index f8459d8eb..000000000 --- a/ui/src/common/SongDatagrid.test.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react' -import { render, fireEvent, screen } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createTheme, ThemeProvider } from '@material-ui/core/styles' -import { DiscSubtitleRow } from './SongDatagrid' - -vi.mock('../subsonic', () => ({ - default: { getDiscCoverArtUrl: () => 'http://localhost/cover.jpg' }, -})) - -vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() })) - -vi.mock('../common', () => ({ AlbumContextMenu: () => null })) - -vi.mock('react-dnd', () => ({ useDrag: () => [{}, vi.fn()] })) - -const record = { - id: 'song-1', - albumId: 'album-1', - album: 'The Album', - discNumber: 2, - discSubtitle: 'Bonus Disc', - updatedAt: '2024-01-01', -} - -const renderRow = (onClick) => - render( - <ThemeProvider theme={createTheme()}> - <table> - <tbody> - <DiscSubtitleRow record={record} onClick={onClick} colSpan={3} /> - </tbody> - </table> - </ThemeProvider>, - ) - -const openLightbox = () => { - fireEvent.click(document.querySelector('img')) - expect(document.querySelector('.ril__closeButton')).toBeTruthy() -} - -describe('DiscSubtitleRow', () => { - beforeEach(() => vi.clearAllMocks()) - - it('plays the disc when the row is clicked', () => { - const onClick = vi.fn() - renderRow(onClick) - fireEvent.click(screen.getByText('Bonus Disc')) - expect(onClick).toHaveBeenCalledWith(2) - }) - - it('does not play the disc when opening the lightbox', () => { - const onClick = vi.fn() - renderRow(onClick) - openLightbox() - expect(onClick).not.toHaveBeenCalled() - }) - - it('does not play the disc when closing the lightbox', () => { - const onClick = vi.fn() - renderRow(onClick) - openLightbox() - fireEvent.click(document.querySelector('.ril__closeButton')) - expect(onClick).not.toHaveBeenCalled() - }) - - it('does not play the disc when clicking the lightbox backdrop', () => { - const onClick = vi.fn() - renderRow(onClick) - openLightbox() - fireEvent.click(document.querySelector('.ril__inner')) - expect(onClick).not.toHaveBeenCalled() - }) -}) diff --git a/ui/src/common/ThumbHashCanvas.jsx b/ui/src/common/ThumbHashCanvas.jsx deleted file mode 100644 index 03d349313..000000000 --- a/ui/src/common/ThumbHashCanvas.jsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect, useRef } from 'react' -import PropTypes from 'prop-types' -import { decode, naturalSize } from '../utils/thumbhash' - -// A thumbhash carries no detail beyond a few dozen pixels; CSS upscales the canvas. -const DECODE_SIZE = 32 - -// bitmapSize prefers the artwork's true ratio, falling back to the aspect the hash itself carries, -// which is quantised to a ratio of small integers and so only approximates the image. -const bitmapSize = (hash, ratio) => { - if (!(ratio > 0) || !Number.isFinite(ratio)) { - try { - return naturalSize(hash) - } catch { - return { width: DECODE_SIZE, height: DECODE_SIZE } - } - } - return ratio >= 1 - ? { - width: DECODE_SIZE, - height: Math.max(1, Math.round(DECODE_SIZE / ratio)), - } - : { - width: Math.max(1, Math.round(DECODE_SIZE * ratio)), - height: DECODE_SIZE, - } -} - -export const ThumbHashCanvas = ({ hash, ratio, fit, className, style }) => { - const canvasRef = useRef(null) - const { width, height } = bitmapSize(hash, ratio) - - useEffect(() => { - if (!hash || !canvasRef.current) { - return - } - const ctx = canvasRef.current.getContext('2d') - if (!ctx) { - return - } - // Clear first so a hash change that fails to decode never leaves a stale frame. - ctx.clearRect(0, 0, width, height) - try { - const pixels = decode(hash, width, height) - const imageData = ctx.createImageData(width, height) - imageData.data.set(pixels) - ctx.putImageData(imageData, 0, 0) - } catch { - // A malformed hash simply leaves the canvas blank. - } - }, [hash, width, height]) - - if (!hash) { - return null - } - return ( - <canvas - ref={canvasRef} - width={width} - height={height} - className={className} - style={{ ...style, objectFit: fit }} - aria-hidden="true" - /> - ) -} - -ThumbHashCanvas.propTypes = { - hash: PropTypes.string, - // Aspect ratio (width / height) of the image this stands in for; the hash's own when omitted. - ratio: PropTypes.number, - fit: PropTypes.oneOf(['cover', 'contain']), - className: PropTypes.string, - style: PropTypes.object, -} diff --git a/ui/src/common/ThumbHashCanvas.test.jsx b/ui/src/common/ThumbHashCanvas.test.jsx deleted file mode 100644 index b1f53fe5c..000000000 --- a/ui/src/common/ThumbHashCanvas.test.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import { render } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { ThumbHashCanvas } from './ThumbHashCanvas' - -// Golden hashes from core/artwork/thumbhash/testdata/golden.json. -const SQUARE = 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I' -const LANDSCAPE = '3wcOFJpwh4eBh3d4iIePgAj3hw==' - -describe('ThumbHashCanvas', () => { - // jsdom has no real 2D context; stub it (tracked) so specs can assert the draw path ran. - let ctxMock - let getContextSpy - beforeEach(() => { - ctxMock = { - clearRect: vi.fn(), - createImageData: vi.fn((w, h) => ({ - data: new Uint8ClampedArray(w * h * 4), - })), - putImageData: vi.fn(), - } - getContextSpy = vi - .spyOn(HTMLCanvasElement.prototype, 'getContext') - .mockReturnValue(ctxMock) - }) - afterEach(() => { - getContextSpy.mockRestore() - }) - - it('renders nothing without a hash', () => { - const { container } = render(<ThumbHashCanvas hash="" />) - expect(container.querySelector('canvas')).toBeNull() - }) - - it('decodes a valid hash and draws non-trivial pixel data', () => { - const { container } = render(<ThumbHashCanvas hash={SQUARE} />) - expect(container.querySelector('canvas')).not.toBeNull() - expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) - const [imageData] = ctxMock.putImageData.mock.calls[0] - expect(imageData.data.some((byte) => byte !== 0)).toBe(true) - }) - - it('decodes into a bitmap shaped like the image, so the blur is not distorted', () => { - const { container } = render( - <ThumbHashCanvas hash={SQUARE} ratio={1200 / 800} />, - ) - expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 21) - const canvas = container.querySelector('canvas') - expect(canvas.width).toBe(32) - expect(canvas.height).toBe(21) - }) - - it('shapes a portrait ratio the other way round', () => { - render(<ThumbHashCanvas hash={SQUARE} ratio={0.5} />) - expect(ctxMock.createImageData).toHaveBeenCalledWith(16, 32) - }) - - it('never collapses an extreme ratio to a zero-sized bitmap', () => { - render(<ThumbHashCanvas hash={SQUARE} ratio={200} />) - expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 1) - }) - - // Unlike a blurhash, a thumbhash carries its own approximate aspect, so an unknown ratio - // falls back to that rather than to a square. - it('falls back to the hash own aspect when the ratio is unknown', () => { - render(<ThumbHashCanvas hash={LANDSCAPE} ratio={0} />) - expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 18) - }) - - it('applies the object-fit it is given, so it lands where the image will', () => { - const { container } = render( - <ThumbHashCanvas hash={SQUARE} ratio={1.5} fit="contain" />, - ) - expect(container.querySelector('canvas').style.objectFit).toBe('contain') - }) - - it('renders a canvas without throwing on a malformed hash, and draws nothing', () => { - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { container } = render( - <ThumbHashCanvas hash="!!!not-a-thumbhash!!!" />, - ) - expect(container.querySelector('canvas')).not.toBeNull() - expect(ctxMock.putImageData).not.toHaveBeenCalled() - spy.mockRestore() - }) - - it('clears the canvas when a hash change fails to decode', () => { - const { rerender } = render(<ThumbHashCanvas hash={SQUARE} />) - expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) - - rerender(<ThumbHashCanvas hash="!!!not-a-thumbhash!!!" />) - - expect(ctxMock.clearRect).toHaveBeenCalledTimes(2) - expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) - }) -}) diff --git a/ui/src/common/artist.js b/ui/src/common/artist.js deleted file mode 100644 index 370700160..000000000 --- a/ui/src/common/artist.js +++ /dev/null @@ -1,6 +0,0 @@ -// Size of an artist's downloadable album-artist content, or undefined when there -// is nothing to download (a missing artist, or no album-artist songs). Download -// and Share only cover album-artist songs, so callers gate on this, not the -// role-inclusive total. -export const artistDownloadSize = (record) => - record?.missing ? undefined : record?.stats?.albumartist?.size diff --git a/ui/src/common/artist.test.js b/ui/src/common/artist.test.js deleted file mode 100644 index eff9935a6..000000000 --- a/ui/src/common/artist.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { artistDownloadSize } from './artist' - -describe('artistDownloadSize', () => { - it('returns the album-artist size', () => { - expect( - artistDownloadSize({ stats: { albumartist: { size: 1024 } } }), - ).toEqual(1024) - }) - - it('returns undefined for a missing artist', () => { - expect( - artistDownloadSize({ - missing: true, - stats: { albumartist: { size: 1024 } }, - }), - ).toBeUndefined() - }) - - it('returns undefined when there is no album-artist content', () => { - expect(artistDownloadSize({ stats: {} })).toBeUndefined() - expect(artistDownloadSize(undefined)).toBeUndefined() - }) -}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index fb8f40f00..362a0ced3 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -1,5 +1,4 @@ export * from './AddToPlaylistButton' -export * from './artist' export * from './ArtistLinkField' export * from './BatchPlayButton' export * from './BitrateField' @@ -11,11 +10,9 @@ export * from './DurationField' export * from './List' export * from './MultiLineTextField' export * from './Pagination' -export * from './perPageStore' export * from './PlayButton' export * from './QuickFilter' export * from './RangeField' -export * from './ReadOnlyFields' export * from './ShuffleAllButton' export * from './SimpleList' export * from './SizeField' @@ -30,15 +27,12 @@ export * from './useAlbumsPerPage' export * from './useGetHandleArtistClick' export * from './useInterval' export * from './useResourceRefresh' -export * from './useScrollRestoration' export * from './useRefreshOnEvents' export * from './useToggleLove' export * from './useTraceUpdate' export * from './Writable' export * from './SongSimpleList' export * from './RatingField' -export * from './RefreshMetadataButton' -export * from './useRefreshMetadata' export * from './useRating' export * from './useSelectedFields' export * from './ToggleFieldsMenu' @@ -50,6 +44,6 @@ export * from './ParticipantsInfo' export * from './OverflowTooltip' export * from './useSearchRefocus' export * from './ImageUploadOverlay' -export * from './ArtworkAvatar' +export * from './CoverArtAvatar' export * from './useImageLoadingState' export * from './useImageUrl' diff --git a/ui/src/common/perPageStore.js b/ui/src/common/perPageStore.js deleted file mode 100644 index 52a8a8f29..000000000 --- a/ui/src/common/perPageStore.js +++ /dev/null @@ -1,11 +0,0 @@ -export const defaultRowsPerPageOptions = [15, 25, 50] - -const key = (resource) => `perPage.${resource}` - -export const getStoredPerPage = (resource, options, fallback = options[0]) => { - const stored = parseInt(localStorage.getItem(key(resource)), 10) - return options.includes(stored) ? stored : fallback -} - -export const setStoredPerPage = (resource, perPage) => - localStorage.setItem(key(resource), String(perPage)) diff --git a/ui/src/common/perPageStore.test.js b/ui/src/common/perPageStore.test.js deleted file mode 100644 index 3aeba3ad8..000000000 --- a/ui/src/common/perPageStore.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { getStoredPerPage, setStoredPerPage } from './perPageStore' - -const options = [15, 25, 50] - -describe('perPageStore', () => { - beforeEach(() => { - localStorage.clear() - }) - - it('round-trips a stored value', () => { - setStoredPerPage('song', 25) - expect(getStoredPerPage('song', options, 15)).toEqual(25) - }) - - it('keys values per resource', () => { - setStoredPerPage('song', 25) - setStoredPerPage('playlist', 50) - expect(getStoredPerPage('song', options, 15)).toEqual(25) - expect(getStoredPerPage('playlist', options, 15)).toEqual(50) - }) - - it('returns the fallback when nothing is stored', () => { - expect(getStoredPerPage('song', options, 15)).toEqual(15) - }) - - it('returns the fallback for garbage values', () => { - localStorage.setItem('perPage.song', 'bogus') - expect(getStoredPerPage('song', options, 15)).toEqual(15) - }) - - it('returns the fallback when the stored value is not a valid option', () => { - setStoredPerPage('album', 90) - expect(getStoredPerPage('album', [18, 36, 72], 18)).toEqual(18) - }) - - it('defaults the fallback to the first option', () => { - expect(getStoredPerPage('song', options)).toEqual(15) - }) -}) diff --git a/ui/src/common/playlistUtils.js b/ui/src/common/playlistUtils.js index 2d0c745ff..74a01d47a 100644 --- a/ui/src/common/playlistUtils.js +++ b/ui/src/common/playlistUtils.js @@ -12,4 +12,4 @@ export const isReadOnly = (ownerId) => { export const isSmartPlaylist = (pls) => !!pls.rules export const canChangeTracks = (pls) => - isWritable(pls.ownerId) && !isSmartPlaylist(pls) && !pls.sync + isWritable(pls.ownerId) && !isSmartPlaylist(pls) diff --git a/ui/src/common/playlistUtils.test.js b/ui/src/common/playlistUtils.test.js index 345b6045d..2c671ecf5 100644 --- a/ui/src/common/playlistUtils.test.js +++ b/ui/src/common/playlistUtils.test.js @@ -74,11 +74,5 @@ describe('playlistUtils', () => { const playlist = { ownerId: 'user1', rules: [] } expect(canChangeTracks(playlist)).toBe(false) }) - - it('returns false if playlist is synced', () => { - localStorage.setItem('userId', 'user1') - const playlist = { ownerId: 'user1', sync: true } - expect(canChangeTracks(playlist)).toBe(false) - }) }) }) diff --git a/ui/src/common/useAlbumsPerPage.jsx b/ui/src/common/useAlbumsPerPage.jsx index 0fb5616c3..6a02bdeb7 100644 --- a/ui/src/common/useAlbumsPerPage.jsx +++ b/ui/src/common/useAlbumsPerPage.jsx @@ -1,5 +1,4 @@ import { useSelector } from 'react-redux' -import { getStoredPerPage } from './perPageStore' const getPerPage = (width) => { if (width === 'xs') return 12 @@ -18,15 +17,10 @@ const getPerPageOptions = (width) => { } export const useAlbumsPerPage = (width) => { - const options = getPerPageOptions(width) - const sessionPerPage = useSelector( - (state) => state?.admin.resources?.album?.list?.params?.perPage, - ) - // Use the session value only when it's valid for the current width, so a - // size picked at a wider breakpoint can't leave an out-of-range selector. - const perPage = options.includes(sessionPerPage) - ? sessionPerPage - : getStoredPerPage('album', options, getPerPage(width)) + const perPage = + useSelector( + (state) => state?.admin.resources?.album?.list?.params?.perPage, + ) || getPerPage(width) - return [perPage, options] + return [perPage, getPerPageOptions(width)] } diff --git a/ui/src/common/useAlbumsPerPage.test.jsx b/ui/src/common/useAlbumsPerPage.test.jsx deleted file mode 100644 index b194a6ef2..000000000 --- a/ui/src/common/useAlbumsPerPage.test.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { useAlbumsPerPage } from './useAlbumsPerPage' -import { setStoredPerPage } from './perPageStore' - -vi.mock('react-redux', () => ({ - useSelector: vi.fn(), -})) - -describe('useAlbumsPerPage', () => { - let mockUseSelector - - beforeEach(async () => { - vi.clearAllMocks() - localStorage.clear() - const { useSelector } = await import('react-redux') - mockUseSelector = vi.mocked(useSelector) - }) - - const setReduxPerPage = (value) => - mockUseSelector.mockImplementation((selector) => - selector({ - admin: { - resources: { album: { list: { params: { perPage: value } } } }, - }, - }), - ) - - it('prefers the redux session value over the stored one', () => { - setReduxPerPage(36) - setStoredPerPage('album', 72) - const { result } = renderHook(() => useAlbumsPerPage('lg')) - expect(result.current[0]).toEqual(36) - }) - - it('falls back to the stored value on fresh load', () => { - setReduxPerPage(undefined) - setStoredPerPage('album', 72) - const { result } = renderHook(() => useAlbumsPerPage('lg')) - expect(result.current[0]).toEqual(72) - }) - - it('ignores stored values invalid for the current width', () => { - setReduxPerPage(undefined) - setStoredPerPage('album', 72) // valid for lg, not for md - const { result } = renderHook(() => useAlbumsPerPage('md')) - expect(result.current[0]).toEqual(12) - }) - - it('returns the responsive default when nothing is stored', () => { - setReduxPerPage(undefined) - const { result } = renderHook(() => useAlbumsPerPage('xl')) - expect(result.current).toEqual([36, [18, 36, 72]]) - }) - - it('ignores a redux value invalid for the current width', () => { - setReduxPerPage(72) // valid for lg, not for md - const { result } = renderHook(() => useAlbumsPerPage('md')) - expect(result.current[0]).toEqual(12) - }) -}) diff --git a/ui/src/common/useImageUrl.js b/ui/src/common/useImageUrl.js index 8a7f2bcc7..9bcc70d74 100644 --- a/ui/src/common/useImageUrl.js +++ b/ui/src/common/useImageUrl.js @@ -43,18 +43,6 @@ export const useImageUrl = (url) => { const [error, setError] = useState(cached?.error || false) const abortedRef = useRef(false) - // Resync during render: otherwise imgUrl holds the previous url's blob for one render, so only - // fromCache can answer "already cached" - and a remembered failure has no blob to paint. - const [fromCache, setFromCache] = useState(!!cached?.blobUrl) - const [trackedUrl, setTrackedUrl] = useState(url) - if (trackedUrl !== url) { - setTrackedUrl(url) - setImgUrl(cached?.blobUrl || null) - setLoading(!!url && !cached) - setError(cached?.error || false) - setFromCache(!!cached?.blobUrl) - } - useEffect(() => { abortedRef.current = false @@ -152,5 +140,5 @@ export const useImageUrl = (url) => { } }, [url]) - return { imgUrl, loading, error, fromCache } + return { imgUrl, loading, error } } diff --git a/ui/src/common/useImageUrl.test.js b/ui/src/common/useImageUrl.test.js index 818732d06..976317105 100644 --- a/ui/src/common/useImageUrl.test.js +++ b/ui/src/common/useImageUrl.test.js @@ -200,7 +200,6 @@ describe('useImageUrl', () => { expect(global.fetch).toHaveBeenCalledTimes(1) expect(result2.current.imgUrl).toBe('blob:mock-url') expect(result2.current.loading).toBe(false) - expect(result2.current.fromCache).toBe(true) }) it('should cache errors and not re-fetch broken URLs', async () => { @@ -231,7 +230,5 @@ describe('useImageUrl', () => { expect(result2.current.error).toBe(true) expect(result2.current.imgUrl).toBeNull() expect(result2.current.loading).toBe(false) - // A remembered failure has no blob, so callers must not treat it as instantly painted. - expect(result2.current.fromCache).toBe(false) }) }) diff --git a/ui/src/common/useRating.jsx b/ui/src/common/useRating.jsx index 82e9bfc06..2eb5d9eca 100644 --- a/ui/src/common/useRating.jsx +++ b/ui/src/common/useRating.jsx @@ -1,12 +1,11 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { useDataProvider, useNotify, useRefresh } from 'react-admin' +import { useDataProvider, useNotify } from 'react-admin' import subsonic from '../subsonic' export const useRating = (resource, record) => { const [loading, setLoading] = useState(false) const notify = useNotify() const dataProvider = useDataProvider() - const refresh = useRefresh() const mountedRef = useRef(false) const rating = record.rating @@ -18,18 +17,23 @@ export const useRating = (resource, record) => { }, []) const refreshRating = useCallback(() => { + // For playlist tracks, refresh both resources to keep data in sync if (record.mediaFileId) { - // A playlistTrack id is a position, not a stable key: rating a song can drop it out - // of a smart playlist, and that position then holds a different track. Refetching - // the row by id would write the neighbour's data under this row, so reload the list. - dataProvider - .getOne('song', { id: record.mediaFileId }) + // This is a playlist track - refresh both the playlist track and the song + const promises = [ + dataProvider.getOne('song', { id: record.mediaFileId }), + dataProvider.getOne('playlistTrack', { + id: record.id, + filter: { playlist_id: record.playlistId }, + }), + ] + + Promise.all(promises) .catch((e) => { // eslint-disable-next-line no-console console.log('Error encountered: ' + e) }) .finally(() => { - refresh() if (mountedRef.current) { setLoading(false) } @@ -48,7 +52,7 @@ export const useRating = (resource, record) => { } }) } - }, [dataProvider, record.id, record.mediaFileId, refresh, resource]) + }, [dataProvider, record.id, record.mediaFileId, record.playlistId, resource]) const rate = (val, id) => { setLoading(true) diff --git a/ui/src/common/useRating.test.js b/ui/src/common/useRating.test.js index ffe9abffd..b1353512e 100644 --- a/ui/src/common/useRating.test.js +++ b/ui/src/common/useRating.test.js @@ -4,8 +4,6 @@ import { useRating } from './useRating' import subsonic from '../subsonic' import { useDataProvider } from 'react-admin' -const mockRefresh = vi.fn() - vi.mock('../subsonic', () => ({ default: { setRating: vi.fn(() => Promise.resolve()), @@ -18,16 +16,13 @@ vi.mock('react-admin', async () => { ...actual, useDataProvider: vi.fn(), useNotify: vi.fn(() => vi.fn()), - useRefresh: vi.fn(() => mockRefresh), } }) describe('useRating', () => { let getOne beforeEach(() => { - getOne = vi.fn((resource, params) => - Promise.resolve({ data: { id: params.id } }), - ) + getOne = vi.fn(() => Promise.resolve()) useDataProvider.mockReturnValue({ getOne }) vi.clearAllMocks() }) @@ -61,9 +56,9 @@ describe('useRating', () => { }) describe('playlist track scenarios', () => { - it('refreshes the song and reloads the list for playlist tracks', async () => { + it('refreshes both playlist track and song for playlist tracks', async () => { const record = { - id: '1', + id: 'pt-1', mediaFileId: 'sg-1', playlistId: 'pl-1', rating: 2, @@ -76,21 +71,18 @@ describe('useRating', () => { // Should rate using the media file ID expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5) - // The row is a position in the playlist, so it cannot be refetched by id: - // rating can drop the track out of a smart playlist and shift every row up - expect(getOne).toHaveBeenCalledTimes(1) + // Should refresh both the playlist track and the song + expect(getOne).toHaveBeenCalledTimes(2) + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-1', + filter: { playlist_id: 'pl-1' }, + }) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - expect(getOne).not.toHaveBeenCalledWith( - 'playlistTrack', - expect.anything(), - ) - expect(mockRefresh).toHaveBeenCalled() }) - it('reloads the list even when the song refresh fails', async () => { - getOne.mockImplementation(() => Promise.reject(new Error('boom'))) + it('includes playlist_id filter when refreshing playlist tracks', async () => { const record = { - id: '5', + id: 'pt-5', mediaFileId: 'sg-10', playlistId: 'pl-123', rating: 1, @@ -100,8 +92,16 @@ describe('useRating', () => { await result.current[0](3, 'sg-10') }) + // Should rate using the media file ID expect(subsonic.setRating).toHaveBeenCalledWith('sg-10', 3) - expect(mockRefresh).toHaveBeenCalled() + + // Should refresh playlist track with correct playlist_id filter + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-5', + filter: { playlist_id: 'pl-123' }, + }) + // Should also refresh the underlying song + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) }) it('only refreshes original resource when no mediaFileId present', async () => { @@ -111,10 +111,9 @@ describe('useRating', () => { await result.current[0](2, 'sg-1') }) - // Should only refresh the original resource (song), without reloading the list + // Should only refresh the original resource (song) expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - expect(mockRefresh).not.toHaveBeenCalled() }) it('does not include playlist_id filter for non-playlist resources', async () => { diff --git a/ui/src/common/useRefreshMetadata.js b/ui/src/common/useRefreshMetadata.js deleted file mode 100644 index d34042919..000000000 --- a/ui/src/common/useRefreshMetadata.js +++ /dev/null @@ -1,16 +0,0 @@ -import { useCallback } from 'react' -import { useDataProvider, useNotify } from 'react-admin' - -export const useRefreshMetadata = () => { - const dataProvider = useDataProvider() - const notify = useNotify() - - return useCallback( - (resource, id) => - dataProvider - .refreshMetadata(resource, id) - .then(() => notify('message.metadataRefreshStarted')) - .catch(() => notify('ra.page.error', 'warning')), - [dataProvider, notify], - ) -} diff --git a/ui/src/common/useResourceRefresh.jsx b/ui/src/common/useResourceRefresh.jsx index b59ae8e88..eabff6f92 100644 --- a/ui/src/common/useResourceRefresh.jsx +++ b/ui/src/common/useResourceRefresh.jsx @@ -63,9 +63,6 @@ import { useRefresh, useDataProvider } from 'react-admin' * - Global refresh: { '*': '*' } or { someResource: ['*'] } * - Specific resources: { album: ['id1', 'id2'], song: ['id3'] } */ -// Resources whose records are media files, and so inherit their album's artwork. -const trackResources = ['song', 'playlistTrack'] - export const useResourceRefresh = (...visibleResources) => { const [lastTime, setLastTime] = useState(Date.now()) const refresh = useRefresh() @@ -73,7 +70,6 @@ export const useResourceRefresh = (...visibleResources) => { const refreshData = useSelector( (state) => state.activity?.refresh || { lastReceived: lastTime }, ) - const loadedResources = useSelector((state) => state.admin?.resources) const { resources, lastReceived } = refreshData if (lastReceived <= lastTime) { @@ -81,49 +77,21 @@ export const useResourceRefresh = (...visibleResources) => { } setLastTime(lastReceived) - const isWatched = (r) => - visibleResources.length === 0 || visibleResources.includes(r) - // A wildcard on a resource this component does not show is somebody else's business: reloading - // the page for it throws away the list the user is looking at. - const hasWildcard = + if ( resources && (resources['*'] === '*' || - Object.entries(resources).some( - ([r, ids]) => isWatched(r) && ids.includes?.('*'), - )) - - if (hasWildcard) { + Object.values(resources).find((v) => v.find((v2) => v2 === '*'))) + ) { refresh() return } - if (!resources) { - return - } - Object.keys(resources).forEach((r) => { - if (isWatched(r)) { - if (resources[r]?.length > 0) { - // Only refetch records already in the store; ones the UI never loaded will - // arrive fresh (with the new artwork) when navigated to, so fetching them is wasteful. - const loaded = loadedResources?.[r]?.data || {} - const ids = resources[r].filter((id) => loaded[id] !== undefined) - if (ids.length > 0) { - dataProvider.getMany(r, { ids }) + if (resources) { + Object.keys(resources).forEach((r) => { + if (visibleResources.length === 0 || visibleResources?.includes(r)) { + if (resources[r]?.length > 0) { + dataProvider.getMany(r, { ids: resources[r] }) } } - } - }) - - // A track with no art of its own is served its album's, so an album's new coverArt id moves its - // tracks' too. The dependent ids are unbounded server-side, but the store knows which are loaded. - if (resources.album?.length > 0) { - const albumIds = new Set(resources.album) - trackResources.filter(isWatched).forEach((r) => { - const ids = Object.values(loadedResources?.[r]?.data || {}) - .filter((t) => albumIds.has(t?.albumId)) - .map((t) => t.id) - if (ids.length > 0) { - dataProvider.getMany(r, { ids }) - } }) } } diff --git a/ui/src/common/useResourceRefresh.test.js b/ui/src/common/useResourceRefresh.test.js index ccd729836..eabd478db 100644 --- a/ui/src/common/useResourceRefresh.test.js +++ b/ui/src/common/useResourceRefresh.test.js @@ -38,27 +38,6 @@ describe('useResourceRefresh', () => { const useDataProviderMock = () => ({ getMany }) let lastTime - // Applies each real selector against a constructed store, so both useSelector - // calls in the hook (refresh payload + loaded records) resolve correctly. - const mockStore = ({ refresh: refreshPayload, loaded = {} }) => { - const state = { - activity: { refresh: refreshPayload }, - admin: { resources: loaded }, - } - vi.spyOn(Redux, 'useSelector').mockImplementation((selector) => - selector(state), - ) - } - - // Turns id lists into the react-admin store shape: { album: { data: { 'al-1': {...} } } } - const asStore = (byResource) => - Object.fromEntries( - Object.entries(byResource).map(([r, ids]) => [ - r, - { data: Object.fromEntries(ids.map((id) => [id, { id }])) }, - ]), - ) - beforeEach(() => { vi.spyOn(React, 'useState').mockImplementation(useStateMock) vi.spyOn(RA, 'useRefresh').mockImplementation(useRefreshMock) @@ -71,7 +50,8 @@ describe('useResourceRefresh', () => { }) it('stores last time checked, to avoid redundant runs', () => { - mockStore({ refresh: { lastReceived: lastTime } }) + const useSelectorMock = () => ({ lastReceived: lastTime }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -80,7 +60,8 @@ describe('useResourceRefresh', () => { it("does not run again if lastTime didn't change", () => { vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState]) - mockStore({ refresh: { lastReceived: lastTime } }) + const useSelectorMock = () => ({ lastReceived: lastTime }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -89,9 +70,11 @@ describe('useResourceRefresh', () => { describe('No visible resources specified', () => { it('triggers a UI refresh when received a "any" resource refresh', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { '*': '*' } }, + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { '*': '*' }, }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -100,9 +83,11 @@ describe('useResourceRefresh', () => { }) it('triggers a UI refresh when received an "any" id', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { album: ['*'] } }, + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { album: ['*'] }, }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -110,14 +95,12 @@ describe('useResourceRefresh', () => { expect(getMany).not.toHaveBeenCalled() }) - it('refetches only the received resources already loaded in the store', () => { - mockStore({ - refresh: { - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, - }, - loaded: asStore({ album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }), + it('triggers a refetch of the resources received', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -126,42 +109,15 @@ describe('useResourceRefresh', () => { expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1', 'al-2'] }) expect(getMany).toHaveBeenCalledWith('song', { ids: ['sg-1', 'sg-2'] }) }) - - it('skips ids that are not loaded in the store', () => { - mockStore({ - refresh: { - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2', 'al-3'] }, - }, - loaded: asStore({ album: ['al-2'] }), - }) - - useResourceRefresh() - - expect(getMany).toHaveBeenCalledTimes(1) - expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-2'] }) - }) - - it('does not fetch when none of the received ids are loaded', () => { - mockStore({ - refresh: { - lastReceived: lastTime, - resources: { artist: ['ar-1', 'ar-2'] }, - }, - loaded: asStore({ artist: ['ar-9'] }), - }) - - useResourceRefresh() - - expect(getMany).not.toHaveBeenCalled() - }) }) describe('Visible resources specified', () => { it('triggers a UI refresh when received a "any" resource refresh', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { '*': '*' } }, + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { '*': '*' }, }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh('album') @@ -169,101 +125,12 @@ describe('useResourceRefresh', () => { expect(getMany).not.toHaveBeenCalled() }) - it('does not refresh the page when the wildcard is on a resource it does not watch', () => { - // Guards other senders: a wildcard on an unwatched resource must not reload this list. - mockStore({ - refresh: { - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2'], song: ['*'] }, - }, - loaded: asStore({ album: ['al-1', 'al-2'] }), - }) - - useResourceRefresh('album') - - expect(refresh).not.toHaveBeenCalled() - expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1', 'al-2'] }) - }) - - it('refetches the loaded songs of a refreshed album', () => { - // A track with no art of its own is served its album's, so its coverArt id moves with the - // album's. The backend cannot know which tracks are loaded; the store can. - mockStore({ - refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, - loaded: { - album: { data: { 'al-1': { id: 'al-1' } } }, - song: { - data: { - 'sg-1': { id: 'sg-1', albumId: 'al-1' }, - 'sg-2': { id: 'sg-2', albumId: 'al-2' }, - }, - }, - }, - }) - - useResourceRefresh('song') - - expect(refresh).not.toHaveBeenCalled() - expect(getMany).toHaveBeenCalledWith('song', { ids: ['sg-1'] }) - }) - - it('fans an album refresh out to loaded playlist tracks, which have their own ids', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, - loaded: { - playlistTrack: { - data: { - 'pt-1': { id: 'pt-1', albumId: 'al-1' }, - 'pt-2': { id: 'pt-2', albumId: 'al-2' }, - }, - }, - }, - }) - - useResourceRefresh('playlistTrack', 'song', 'playlist') - - expect(refresh).not.toHaveBeenCalled() - expect(getMany).toHaveBeenCalledWith('playlistTrack', { ids: ['pt-1'] }) - }) - - it('does not fan out to track resources the component does not show', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, - loaded: { - album: { data: { 'al-1': { id: 'al-1' } } }, - song: { data: { 'sg-1': { id: 'sg-1', albumId: 'al-1' } } }, - }, - }) - - useResourceRefresh('album') - - expect(refresh).not.toHaveBeenCalled() - expect(getMany).toHaveBeenCalledTimes(1) - expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1'] }) - }) - - it('does not refetch songs when no loaded song belongs to the refreshed album', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { album: ['al-9'] } }, - loaded: { - song: { data: { 'sg-1': { id: 'sg-1', albumId: 'al-1' } } }, - }, - }) - - useResourceRefresh('song') - - expect(refresh).not.toHaveBeenCalled() - expect(getMany).not.toHaveBeenCalled() - }) - - it('refetches the received resources if they are visible and loaded', () => { - mockStore({ - refresh: { - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, - }, - loaded: asStore({ album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }), + it('triggers a refetch of the resources received if they are visible', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh('song') diff --git a/ui/src/common/useScrollRestoration.jsx b/ui/src/common/useScrollRestoration.jsx deleted file mode 100644 index 94e5cb2fd..000000000 --- a/ui/src/common/useScrollRestoration.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect, useLayoutEffect, useRef } from 'react' -import { useHistory, useLocation } from 'react-router-dom' - -// Keyed on the route because hash history assigns no location.key, so every page would -// otherwise share one slot and overwrite the offset we came back for. -const positions = new Map() -const MAX_ENTRIES = 50 -const MAX_FRAMES = 60 - -export const useScrollRestoration = (ready = true) => { - const { pathname, search } = useLocation() - const history = useHistory() - const key = pathname + search - const handled = useRef(null) - const latest = useRef(0) - - useEffect(() => { - latest.current = window.scrollY - const track = () => { - latest.current = window.scrollY - } - window.addEventListener('scroll', track, { passive: true }) - return () => window.removeEventListener('scroll', track) - }, [key]) - - // Layout cleanup, so the offset is banked while this page is torn down. A passive one runs - // after the incoming page tops itself, and this page's listener records that 0 first. - useLayoutEffect( - () => () => { - positions.delete(key) - positions.set(key, latest.current) - if (positions.size > MAX_ENTRIES) { - positions.delete(positions.keys().next().value) - } - }, - [key], - ) - - // Layout effect: a passive one runs after paint, so the list would be painted at the old - // offset first and visibly jump. - useLayoutEffect(() => { - if (!ready || handled.current === key) return - handled.current = key - const saved = positions.get(key) - const top = history.action === 'POP' && saved !== undefined ? saved : 0 - window.scrollTo({ top }) - if (!top || Math.round(window.scrollY) === top) return - - // The page can still be filling in, and until it is tall enough the browser clamps us to - // the top. Keep asking until the offset sticks, yielding if anything else moves us. - let frame - let frames = 0 - let landed = Math.round(window.scrollY) - const retry = () => { - const y = Math.round(window.scrollY) - if (y === top || y !== landed || ++frames > MAX_FRAMES) return - window.scrollTo({ top }) - landed = Math.round(window.scrollY) - frame = requestAnimationFrame(retry) - } - frame = requestAnimationFrame(retry) - return () => cancelAnimationFrame(frame) - }, [ready, key, history.action]) -} diff --git a/ui/src/common/useScrollRestoration.test.jsx b/ui/src/common/useScrollRestoration.test.jsx deleted file mode 100644 index 7ca67a724..000000000 --- a/ui/src/common/useScrollRestoration.test.jsx +++ /dev/null @@ -1,222 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks' -import { render } from '@testing-library/react' -import { useLayoutEffect } from 'react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' - -const mockLocation = { pathname: '/album', search: '' } -const mockHistory = { action: 'PUSH' } -vi.mock('react-router-dom', () => ({ - useLocation: () => mockLocation, - useHistory: () => mockHistory, -})) - -import { useScrollRestoration } from './useScrollRestoration' - -describe('useScrollRestoration', () => { - beforeEach(() => { - window.scrollTo = vi.fn() - window.scrollY = 0 - mockLocation.pathname = '/album' - mockLocation.search = '' - mockHistory.action = 'PUSH' - }) - afterEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - }) - - const scrollTo = (y) => { - window.scrollY = y - act(() => { - window.dispatchEvent(new Event('scroll')) - }) - } - - // Returns to a page whose rows have not arrived, so scrollTo still clamps. The returned setter - // grows the page to the height it reaches once they do. - const returnToUnfilledPage = (saved) => { - vi.useFakeTimers() - let maxScroll = saved - window.scrollTo = vi.fn(({ top }) => { - window.scrollY = Math.min(top, maxScroll) - }) - - const first = renderHook(() => useScrollRestoration()) - scrollTo(saved) - first.unmount() - - maxScroll = 0 - window.scrollY = 0 - mockHistory.action = 'POP' - renderHook(() => useScrollRestoration()) - return (height) => (maxScroll = height) - } - - const advanceFrames = () => act(() => vi.advanceTimersByTime(100)) - - it('starts a pushed route at the top', () => { - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }) - }) - - it('restores the saved offset when returning to an entry', () => { - const first = renderHook(() => useScrollRestoration()) - scrollTo(640) - first.unmount() - - mockLocation.pathname = '/album/al-1/show' - const second = renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 }) - second.unmount() - - mockLocation.pathname = '/album' - mockHistory.action = 'POP' - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 640 }) - }) - - it('tops a pushed route even if that key was seen before', () => { - const first = renderHook(() => useScrollRestoration()) - scrollTo(500) - first.unmount() - - mockHistory.action = 'PUSH' - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 }) - }) - - // Restoring before the rows exist leaves the document too short: the browser clamps to the - // top and the offset is lost with no error. - it('waits for readiness before touching the scroll position', () => { - const { rerender } = renderHook( - ({ ready }) => useScrollRestoration(ready), - { - initialProps: { ready: false }, - }, - ) - expect(window.scrollTo).not.toHaveBeenCalled() - - rerender({ ready: true }) - expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }) - }) - - // Hash history assigns no location.key, so an implementation keyed on it would collapse every - // route into one slot and the detail page would erase the list's offset before we returned. - it('keeps a separate offset per route', () => { - const list = renderHook(() => useScrollRestoration()) - scrollTo(900) - list.unmount() - - mockLocation.pathname = '/album/al-1/show' - const detail = renderHook(() => useScrollRestoration()) - scrollTo(0) - detail.unmount() - - mockLocation.pathname = '/album' - mockHistory.action = 'POP' - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 }) - }) - - // Without the per-instance guard this fires again when readiness flickers, fighting a user who - // has already started scrolling. Nothing else in the suite pins it. - it('does not scroll again when readiness flickers on the same route', () => { - const { rerender } = renderHook( - ({ ready }) => useScrollRestoration(ready), - { - initialProps: { ready: true }, - }, - ) - expect(window.scrollTo).toHaveBeenCalledTimes(1) - - rerender({ ready: false }) - rerender({ ready: true }) - expect(window.scrollTo).toHaveBeenCalledTimes(1) - }) - - it('scrolls once per entry, not on every re-render', () => { - const { rerender } = renderHook(() => useScrollRestoration()) - rerender() - rerender() - expect(window.scrollTo).toHaveBeenCalledTimes(1) - }) - - // The artist page reports ready as soon as its header record is cached, while the albums below - // are still loading. A single scrollTo lands on a viewport-tall document and is clamped to 0. - it('keeps trying until the page is tall enough to hold the offset', () => { - const fillPageTo = returnToUnfilledPage(800) - expect(window.scrollY).toBe(0) - - fillPageTo(1200) - advanceFrames() - expect(window.scrollY).toBe(800) - }) - - it('stops retrying once the user scrolls somewhere else', () => { - const fillPageTo = returnToUnfilledPage(800) - - fillPageTo(1200) - window.scrollY = 300 - advanceFrames() - expect(window.scrollY).toBe(300) - }) - - // A trackpad back-swipe keeps firing wheel momentum through the restore, so treating input as - // the yield signal cancels the gesture's own restore. - it('keeps restoring while wheel momentum fires, as a back-swipe does', () => { - const fillPageTo = returnToUnfilledPage(800) - - act(() => { - for (let i = 0; i < 10; i++) { - window.dispatchEvent( - new WheelEvent('wheel', { deltaX: -30, deltaY: 0 }), - ) - } - }) - fillPageTo(1200) - advanceFrames() - expect(window.scrollY).toBe(800) - }) - - // The incoming page tops itself in its own layout effect, while this page's scroll listener is - // still attached. Banking the offset any later than the teardown records that 0 instead. - it('records the offset before the next page scrolls to the top', () => { - const Outgoing = () => { - useScrollRestoration() - return null - } - const Incoming = () => { - useLayoutEffect(() => { - window.scrollY = 0 - window.dispatchEvent(new Event('scroll')) - }, []) - return null - } - - const { rerender } = render(<Outgoing />) - scrollTo(1400) - - mockLocation.pathname = '/album/al-1/show' - rerender(<Incoming />) - - mockLocation.pathname = '/album' - mockHistory.action = 'POP' - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 1400 }) - }) - - it('keeps saving after the restore, so leaving again remembers the new offset', () => { - const first = renderHook(() => useScrollRestoration()) - scrollTo(200) - first.unmount() - - mockHistory.action = 'POP' - const second = renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 200 }) - scrollTo(900) - second.unmount() - - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 }) - }) -}) diff --git a/ui/src/common/useToggleLove.jsx b/ui/src/common/useToggleLove.jsx index 22468cbe1..3f98a2e21 100644 --- a/ui/src/common/useToggleLove.jsx +++ b/ui/src/common/useToggleLove.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { useDataProvider, useNotify, useRefresh } from 'react-admin' +import { useDataProvider, useNotify } from 'react-admin' import subsonic from '../subsonic' export const useToggleLove = (resource, record = {}) => { @@ -15,32 +15,33 @@ export const useToggleLove = (resource, record = {}) => { }, []) const dataProvider = useDataProvider() - const refresh = useRefresh() const refreshRecord = useCallback(() => { - // A playlistTrack id is a position, not a stable key: loving a song can drop it out of - // a smart playlist, and that position then holds a different track. Refetching the row - // by id would write the neighbour's data under this row, so reload the list instead. - const isPlaylistTrack = !!record.mediaFileId - const target = isPlaylistTrack - ? { resource: 'song', params: { id: record.mediaFileId } } - : { resource, params: { id: record.id } } + const promises = [] - dataProvider - .getOne(target.resource, target.params) + // Always refresh the original resource + const params = { id: record.id } + if (record.playlistId) { + params.filter = { playlist_id: record.playlistId } + } + promises.push(dataProvider.getOne(resource, params)) + + // If we have a mediaFileId, also refresh the song + if (record.mediaFileId) { + promises.push(dataProvider.getOne('song', { id: record.mediaFileId })) + } + + Promise.all(promises) .catch((e) => { // eslint-disable-next-line no-console console.log('Error encountered: ' + e) }) .finally(() => { - if (isPlaylistTrack) { - refresh() - } if (mountedRef.current) { setLoading(false) } }) - }, [dataProvider, record.mediaFileId, record.id, refresh, resource]) + }, [dataProvider, record.mediaFileId, record.id, record.playlistId, resource]) const toggleLove = () => { const toggle = record.starred ? subsonic.unstar : subsonic.star diff --git a/ui/src/common/useToggleLove.test.js b/ui/src/common/useToggleLove.test.js index 10db1b3c3..640e9ff89 100644 --- a/ui/src/common/useToggleLove.test.js +++ b/ui/src/common/useToggleLove.test.js @@ -4,8 +4,6 @@ import { useToggleLove } from './useToggleLove' import subsonic from '../subsonic' import { useDataProvider } from 'react-admin' -const mockRefresh = vi.fn() - vi.mock('../subsonic', () => ({ default: { star: vi.fn(() => Promise.resolve()), @@ -19,7 +17,6 @@ vi.mock('react-admin', async () => { ...actual, useDataProvider: vi.fn(), useNotify: vi.fn(() => vi.fn()), - useRefresh: vi.fn(() => mockRefresh), } }) @@ -61,9 +58,9 @@ describe('useToggleLove', () => { }) describe('playlist track scenarios', () => { - it('refreshes the song and reloads the list for playlist tracks', async () => { + it('refreshes both playlist track and song for playlist tracks', async () => { const record = { - id: '1', + id: 'pt-1', mediaFileId: 'sg-1', playlistId: 'pl-1', starred: false, @@ -78,21 +75,18 @@ describe('useToggleLove', () => { // Should star using the media file ID expect(subsonic.star).toHaveBeenCalledWith('sg-1') - // The row is a position in the playlist, so it cannot be refetched by id: - // loving can drop the track out of a smart playlist and shift every row up - expect(getOne).toHaveBeenCalledTimes(1) + // Should refresh both the playlist track and the song + expect(getOne).toHaveBeenCalledTimes(2) + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-1', + filter: { playlist_id: 'pl-1' }, + }) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - expect(getOne).not.toHaveBeenCalledWith( - 'playlistTrack', - expect.anything(), - ) - expect(mockRefresh).toHaveBeenCalled() }) - it('reloads the list even when the song refresh fails', async () => { - getOne.mockImplementation(() => Promise.reject(new Error('boom'))) + it('includes playlist_id filter when refreshing playlist tracks', async () => { const record = { - id: '5', + id: 'pt-5', mediaFileId: 'sg-10', playlistId: 'pl-123', starred: true, @@ -104,8 +98,16 @@ describe('useToggleLove', () => { await result.current[0]() }) + // Should unstar using the media file ID expect(subsonic.unstar).toHaveBeenCalledWith('sg-10') - expect(mockRefresh).toHaveBeenCalled() + + // Should refresh playlist track with correct playlist_id filter + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-5', + filter: { playlist_id: 'pl-123' }, + }) + // Should also refresh the underlying song + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) }) it('only refreshes original resource when no mediaFileId present', async () => { @@ -115,10 +117,9 @@ describe('useToggleLove', () => { await result.current[0]() }) - // Should only refresh the original resource (song), without reloading the list + // Should only refresh the original resource (song) expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - expect(mockRefresh).not.toHaveBeenCalled() }) it('does not include playlist_id filter for non-playlist resources', async () => { diff --git a/ui/src/config.js b/ui/src/config.js index 62b3cb822..39f0cd467 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -32,15 +32,12 @@ const defaultConfig = { listenBrainzEnabled: true, enableExternalServices: true, enableCoverAnimation: true, - pidAlbum: 'musicbrainz_albumid|albumartistid,album,albumversion,releasedate', // See consts.DefaultAlbumPID - pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', // See consts.DefaultTrackPID enableNowPlaying: true, playbackReportIntervalMs: 60000, devShowArtistPage: true, devUIShowConfig: true, devNewEventStream: false, enableReplayGain: true, - enableQuickConnect: false, defaultDownsamplingFormat: 'opus', publicBaseUrl: '/share', separator: '/', diff --git a/ui/src/consts.js b/ui/src/consts.js index 46c76e194..472cd4940 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -31,9 +31,3 @@ export const DEFAULT_SHARE_BITRATE = 128 export const BITRATE_CHOICES = [ 32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320, ].map((b) => ({ id: b, name: b.toString() })) - -// 0 is a valid stored value ("no default bit rate") that BITRATE_CHOICES cannot express. -export const TRANSCODING_BITRATE_CHOICES = [ - { id: 0, name: 'resources.transcoding.choices.noDefaultBitRate' }, - ...BITRATE_CHOICES, -] diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index 706486fa6..3820f658d 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid' import { baseUrl } from '../utils' import config from '../config' import { jwtDecode } from 'jwt-decode' +import { removeHomeCache } from '../utils/removeHomeCache' const customAuthorizationHeader = 'X-ND-Authorization' export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' @@ -26,6 +27,7 @@ const httpClient = (url, options = {}) => { localStorage.setItem('userId', decoded.uid) // Avoid going to create admin dialog after logout/login without a refresh config.firstTime = false + removeHomeCache() } return response }) diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index 7de20bcce..268d3668d 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -4,8 +4,6 @@ import { REST_URL } from '../consts' const dataProvider = jsonServerProvider(REST_URL, httpClient) -const REFRESH_KIND = { album: 'al', artist: 'ar' } - const isAdmin = () => { const role = localStorage.getItem('role') return role === 'admin' @@ -139,21 +137,14 @@ const updateUser = async (params) => { data: userData, }) - // 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) { + // Then handle library associations for non-admin users + if (!userData.isAdmin && libraryIds !== undefined) { await handleUserLibraryAssociation(userId, libraryIds) } return userResponse } -// ra-data-json-server merges the request body into the result; re-read so the plaintext key is never cached -const createPlayer = async (resource, params) => { - const { data } = await dataProvider.create(resource, params) - return dataProvider.getOne(resource, { id: data.id }) -} - const wrapperDataProvider = { ...dataProvider, getList: (resource, params) => { @@ -200,9 +191,6 @@ const wrapperDataProvider = { return createUser(params) } const [r, p] = mapResource(resource, params) - if (resource === 'player') { - return createPlayer(r, p) - } return dataProvider.create(r, p) }, delete: (resource, params) => { @@ -227,26 +215,11 @@ const wrapperDataProvider = { ({ json }) => ({ data: json }), ) }, - lookupQuickConnect: (code) => - httpClient( - `${REST_URL}/quickconnect?code=${encodeURIComponent(code)}`, - ).then(({ json }) => ({ data: json })), - authorizeQuickConnect: (code) => - httpClient(`${REST_URL}/quickconnect/authorize`, { - method: 'POST', - body: JSON.stringify({ code }), - }).then(({ json }) => ({ data: json })), inspect: (songId) => { return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({ data: json, })) }, - // The endpoint answers 204 with no body, but react-admin rejects any response without a - // `data` key, so the id stands in for one. - refreshMetadata: (resource, id) => - httpClient(`${REST_URL}/metadata/${REFRESH_KIND[resource]}/${id}/refresh`, { - method: 'POST', - }).then(() => ({ data: { id } })), } export default wrapperDataProvider diff --git a/ui/src/dataProvider/wrapperDataProvider.test.js b/ui/src/dataProvider/wrapperDataProvider.test.js deleted file mode 100644 index 1c33aad5d..000000000 --- a/ui/src/dataProvider/wrapperDataProvider.test.js +++ /dev/null @@ -1,138 +0,0 @@ -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 }, - }), - ) - }) - }) - - describe('create player', () => { - it('returns the server record, never the plaintext API key', async () => { - const data = { name: 'Phone', apiKey: 'nds_0123456789abcdefghijkl' } - const saved = { id: 'p1', name: 'Phone', hasApiKey: true, userId: 'u1' } - mockProvider.create.mockResolvedValue({ data: { ...data, id: 'p1' } }) - mockProvider.getOne.mockResolvedValue({ data: saved }) - - const result = await wrapperDataProvider.create('player', { data }) - - expect(mockProvider.create).toHaveBeenCalledWith('player', { data }) - expect(mockProvider.getOne).toHaveBeenCalledWith('player', { id: 'p1' }) - expect(result.data).toEqual(saved) - }) - }) - - describe('refreshMetadata', () => { - it('posts to the album metadata refresh endpoint', () => { - mockHttpClient.mockResolvedValue({ json: {} }) - wrapperDataProvider.refreshMetadata('album', 'al-1') - expect(mockHttpClient).toHaveBeenCalledWith( - expect.stringContaining('/metadata/al/al-1/refresh'), - { method: 'POST' }, - ) - }) - - it('posts to the artist metadata refresh endpoint', () => { - mockHttpClient.mockResolvedValue({ json: {} }) - wrapperDataProvider.refreshMetadata('artist', 'ar-1') - expect(mockHttpClient).toHaveBeenCalledWith( - expect.stringContaining('/metadata/ar/ar-1/refresh'), - { method: 'POST' }, - ) - }) - - // react-admin rejects a custom method whose response has no `data` key, and the - // endpoint answers 204 with no body. - it('resolves to a react-admin shaped response', async () => { - mockHttpClient.mockResolvedValue({ - status: 204, - body: '', - json: undefined, - }) - await expect( - wrapperDataProvider.refreshMetadata('album', 'al-1'), - ).resolves.toEqual({ data: { id: 'al-1' } }) - }) - }) -}) diff --git a/ui/src/dialogs/DownloadMenuDialog.jsx b/ui/src/dialogs/DownloadMenuDialog.jsx index 61e84a08a..2104cbcad 100644 --- a/ui/src/dialogs/DownloadMenuDialog.jsx +++ b/ui/src/dialogs/DownloadMenuDialog.jsx @@ -8,9 +8,8 @@ import { DialogTitle, } from '@material-ui/core' import subsonic from '../subsonic' -import { closeDownloadMenu, DOWNLOAD_MENU_ARTIST } from '../actions' +import { closeDownloadMenu } from '../actions' import { formatBytes } from '../utils' -import { artistDownloadSize } from '../common/artist' import { useTranscodingOptions } from './useTranscodingOptions' const DownloadMenuDialog = () => { @@ -23,12 +22,6 @@ const DownloadMenuDialog = () => { const { TranscodingOptionsInput, format, maxBitRate, originalFormat } = useTranscodingOptions() - // Artist downloads only include album-artist songs, so show that size - const downloadSize = - recordType === DOWNLOAD_MENU_ARTIST - ? artistDownloadSize(record) - : record?.size - const handleClose = (e) => { dispatch(closeDownloadMenu()) e.stopPropagation() @@ -62,7 +55,7 @@ const DownloadMenuDialog = () => { smart_count: 1, }).toLocaleLowerCase(), name: record?.name || record?.title, - size: formatBytes(downloadSize), + size: formatBytes(record?.size), })} </DialogTitle> <DialogContent> diff --git a/ui/src/dialogs/DownloadMenuDialog.test.jsx b/ui/src/dialogs/DownloadMenuDialog.test.jsx deleted file mode 100644 index b9ce7f077..000000000 --- a/ui/src/dialogs/DownloadMenuDialog.test.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import * as React from 'react' -import { TestContext } from 'ra-test' -import { render, screen, cleanup } from '@testing-library/react' -import { describe, afterEach, it, expect, vi } from 'vitest' -import DownloadMenuDialog from './DownloadMenuDialog' -import { DOWNLOAD_MENU_ALBUM, DOWNLOAD_MENU_ARTIST } from '../actions' - -vi.mock('./useTranscodingOptions', () => ({ - useTranscodingOptions: () => ({ - TranscodingOptionsInput: () => null, - format: '', - maxBitRate: 0, - originalFormat: true, - }), -})) - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useTranslate: () => (key, opts) => - opts?.size ? `${key}:${opts.name}:${opts.size}` : key, - } -}) - -const renderDialog = (record, recordType) => - render( - <TestContext - initialState={{ downloadMenuDialog: { open: true, record, recordType } }} - > - <DownloadMenuDialog /> - </TestContext>, - ) - -describe('DownloadMenuDialog', () => { - afterEach(cleanup) - - it('shows the album-artist size (not the total) for an artist download', () => { - renderDialog( - { - id: 'ar1', - name: 'Artist', - size: 999999999, - stats: { albumartist: { size: 1024 } }, - }, - DOWNLOAD_MENU_ARTIST, - ) - expect(screen.getByText(/:Artist:1 KB$/)).toBeInTheDocument() - }) - - it('shows the total size for an album download', () => { - renderDialog( - { id: 'al1', name: 'Album', size: 1024 * 1024 }, - DOWNLOAD_MENU_ALBUM, - ) - expect(screen.getByText(/:Album:1 MB$/)).toBeInTheDocument() - }) -}) diff --git a/ui/src/dialogs/QuickConnectDialog.jsx b/ui/src/dialogs/QuickConnectDialog.jsx deleted file mode 100644 index cde7e9eaf..000000000 --- a/ui/src/dialogs/QuickConnectDialog.jsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useState } from 'react' -import PropTypes from 'prop-types' -import { useDataProvider, useNotify, useTranslate } from 'react-admin' -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - TextField, -} from '@material-ui/core' - -export const QuickConnectDialog = ({ open, onClose }) => { - const translate = useTranslate() - const notify = useNotify() - const dataProvider = useDataProvider() - const [code, setCode] = useState('') - const [pending, setPending] = useState(null) - const [loading, setLoading] = useState(false) - - const handleClose = () => { - setCode('') - setPending(null) - onClose() - } - - const handleError = (error) => { - setPending(null) - const invalid = error?.status === 404 || error?.status === 409 - notify( - invalid ? 'message.quickConnectInvalidCode' : 'message.quickConnectError', - 'warning', - ) - } - - const run = (request, onSuccess) => { - setLoading(true) - request - .then(({ data }) => onSuccess(data)) - .catch(handleError) - .finally(() => setLoading(false)) - } - - const lookup = (event) => { - event.preventDefault() - run(dataProvider.lookupQuickConnect(code), setPending) - } - - const approve = () => - run(dataProvider.authorizeQuickConnect(code), (data) => { - notify('message.quickConnectApproved', 'success', { - app: data.appName, - device: data.deviceName, - }) - handleClose() - }) - - return ( - <Dialog - open={open} - onClose={handleClose} - aria-labelledby="quick-connect-dialog" - fullWidth - maxWidth="xs" - > - <DialogTitle id="quick-connect-dialog"> - {translate('menu.quickConnect.name')} - </DialogTitle> - {pending ? ( - <> - <DialogContent> - <DialogContentText> - {translate('menu.quickConnect.confirm', { - app: pending.appName, - version: pending.appVersion, - device: pending.deviceName, - })} - </DialogContentText> - </DialogContent> - <DialogActions> - <Button onClick={() => setPending(null)} color="primary"> - {translate('ra.action.back')} - </Button> - <Button onClick={approve} color="primary" disabled={loading}> - {translate('menu.quickConnect.approve')} - </Button> - </DialogActions> - </> - ) : ( - <form onSubmit={lookup}> - <DialogContent> - <DialogContentText> - {translate('menu.quickConnect.help')} - </DialogContentText> - <TextField - id="quickConnectCode" - variant="outlined" - fullWidth - autoFocus - label={translate('menu.quickConnect.code')} - value={code} - onChange={(event) => setCode(event.target.value)} - inputProps={{ inputMode: 'numeric', autoComplete: 'off' }} - /> - </DialogContent> - <DialogActions> - <Button onClick={handleClose} color="primary"> - {translate('ra.action.cancel')} - </Button> - <Button - type="submit" - color="primary" - disabled={!code.trim() || loading} - > - {translate('menu.quickConnect.continue')} - </Button> - </DialogActions> - </form> - )} - </Dialog> - ) -} - -QuickConnectDialog.propTypes = { - open: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, -} diff --git a/ui/src/dialogs/QuickConnectDialog.test.jsx b/ui/src/dialogs/QuickConnectDialog.test.jsx deleted file mode 100644 index 5dda1cbdf..000000000 --- a/ui/src/dialogs/QuickConnectDialog.test.jsx +++ /dev/null @@ -1,103 +0,0 @@ -import * as React from 'react' -import { TestContext } from 'ra-test' -import { DataProviderContext } from 'react-admin' -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' -import { describe, afterEach, it, expect, vi } from 'vitest' -import { QuickConnectDialog } from './QuickConnectDialog' - -const finamp = { appName: 'Finamp', appVersion: '1.0.0', deviceName: 'Pixel 7' } - -const renderDialog = (dataProvider, onClose = vi.fn()) => { - render( - <DataProviderContext.Provider value={dataProvider}> - <TestContext initialState={{ admin: { ui: { optimistic: false } } }}> - <QuickConnectDialog open={true} onClose={onClose} /> - </TestContext> - </DataProviderContext.Provider>, - ) - return onClose -} - -const enterCode = (code) => { - fireEvent.change(screen.getByRole('textbox'), { target: { value: code } }) - fireEvent.click(screen.getByText('menu.quickConnect.continue')) -} - -describe('QuickConnectDialog', () => { - afterEach(cleanup) - - it('shows the device before approving the code', async () => { - const dataProvider = { - lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), - authorizeQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), - } - const onClose = renderDialog(dataProvider) - - enterCode('123 456') - await screen.findByText('menu.quickConnect.approve') - expect(dataProvider.lookupQuickConnect.mock.calls[0][0]).toBe('123 456') - expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled() - - fireEvent.click(screen.getByText('menu.quickConnect.approve')) - await waitFor(() => expect(onClose).toHaveBeenCalled()) - expect(dataProvider.authorizeQuickConnect.mock.calls[0][0]).toBe('123 456') - }) - - it('goes back to the code input', async () => { - const dataProvider = { - lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), - authorizeQuickConnect: vi.fn(), - } - renderDialog(dataProvider) - - enterCode('123456') - await screen.findByText('menu.quickConnect.approve') - fireEvent.click(screen.getByText('ra.action.back')) - - expect(screen.getByRole('textbox')).toHaveValue('123456') - expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled() - }) - - it('stays on the code input when the code is unknown', async () => { - const dataProvider = { - lookupQuickConnect: vi.fn().mockRejectedValue({ status: 404 }), - authorizeQuickConnect: vi.fn(), - } - const onClose = renderDialog(dataProvider) - - enterCode('000000') - await waitFor(() => - expect(dataProvider.lookupQuickConnect).toHaveBeenCalled(), - ) - expect(screen.getByRole('textbox')).toBeInTheDocument() - expect(screen.queryByText('menu.quickConnect.approve')).toBeNull() - expect(onClose).not.toHaveBeenCalled() - }) - - it('returns to the code input when approval fails', async () => { - const dataProvider = { - lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), - authorizeQuickConnect: vi.fn().mockRejectedValue({ status: 409 }), - } - const onClose = renderDialog(dataProvider) - - enterCode('123456') - fireEvent.click(await screen.findByText('menu.quickConnect.approve')) - - await screen.findByRole('textbox') - expect(onClose).not.toHaveBeenCalled() - }) - - it('disables Continue until a code is typed', () => { - renderDialog({ lookupQuickConnect: vi.fn() }) - expect( - screen.getByText('menu.quickConnect.continue').closest('button'), - ).toBeDisabled() - }) -}) diff --git a/ui/src/dialogs/SelectPlaylistInput.jsx b/ui/src/dialogs/SelectPlaylistInput.jsx index 2f040a7f7..847107523 100644 --- a/ui/src/dialogs/SelectPlaylistInput.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.jsx @@ -16,7 +16,7 @@ import { import AddIcon from '@material-ui/icons/Add' import { useGetList, useTranslate } from 'react-admin' import PropTypes from 'prop-types' -import { canChangeTracks } from '../common' +import { isWritable } from '../common' import { makeStyles } from '@material-ui/core' const useStyles = makeStyles((theme) => ({ @@ -268,7 +268,8 @@ export const SelectPlaylistInput = ({ onChange }) => { ) const options = - ids && ids.map((id) => data[id]).filter((option) => canChangeTracks(option)) + ids && + ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId)) // Filter playlists based on search text const filteredOptions = diff --git a/ui/src/dialogs/SelectPlaylistInput.test.jsx b/ui/src/dialogs/SelectPlaylistInput.test.jsx index 753d12c03..4ffcdf0b6 100644 --- a/ui/src/dialogs/SelectPlaylistInput.test.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.test.jsx @@ -16,7 +16,6 @@ const mockPlaylists = [ { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, { id: 'playlist-3', name: 'Electronic Beats', ownerId: 'admin' }, { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, // Not writable by admin - { id: 'playlist-5', name: 'Synced List', ownerId: 'admin', sync: true }, ] const mockIndexedData = { @@ -28,12 +27,6 @@ const mockIndexedData = { ownerId: 'admin', }, 'playlist-4': { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, - 'playlist-5': { - id: 'playlist-5', - name: 'Synced List', - ownerId: 'admin', - sync: true, - }, } const createTestComponent = ( @@ -96,8 +89,6 @@ describe('SelectPlaylistInput', () => { // Should not show playlists not owned by admin (not writable) expect(screen.queryByText('Chill Vibes')).not.toBeInTheDocument() - // Should not show synced playlists (their tracks are not editable) - expect(screen.queryByText('Synced List')).not.toBeInTheDocument() }) it('should filter playlists based on search input', async () => { diff --git a/ui/src/dialogs/index.js b/ui/src/dialogs/index.js index 3234e4685..86586aef0 100644 --- a/ui/src/dialogs/index.js +++ b/ui/src/dialogs/index.js @@ -2,5 +2,4 @@ export * from './AboutDialog' export * from './SelectPlaylistInput' export * from './ListenBrainzTokenDialog' export * from './SaveQueueDialog' -export * from './QuickConnectDialog' export * from './Dialogs' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index f694ea75f..74fb23ab9 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -83,8 +83,7 @@ "grouping": "Grouping", "media": "Media", "mood": "Mood", - "missing": "Missing", - "played": "Played" + "missing": "Missing" }, "actions": { "playAll": "Play", @@ -94,8 +93,7 @@ "shuffle": "Shuffle", "addToPlaylist": "Add to Playlist", "download": "Download", - "info": "Get Info", - "refresh": "Refresh Metadata" + "info": "Get Info" }, "lists": { "all": "All", @@ -157,13 +155,11 @@ "currentPassword": "Current Password", "newPassword": "New Password", "token": "Token", - "libraries": "Libraries", - "scrobbleFilter": "Scrobble filter" + "libraries": "Libraries" }, "helperTexts": { "name": "Changes to your name will only be reflected on next login", - "libraries": "Select specific libraries for this user, or leave empty to use default libraries", - "scrobbleFilter": "Songs matching these smart playlist rules are not sent to Last.fm, ListenBrainz or scrobbler plugins. Uses the same JSON syntax and behavior as smart playlists. Example: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leave empty to scrobble everything. Local play counts are not affected." + "libraries": "Select specific libraries for this user, or leave empty to use default libraries" }, "notifications": { "created": "User created", @@ -171,8 +167,7 @@ "deleted": "User deleted" }, "validation": { - "librariesRequired": "At least one library must be selected for non-admin users", - "invalidScrobbleFilter": "Must be valid smart playlist rules. Limit, offset and refresh delay are not supported." + "librariesRequired": "At least one library must be selected for non-admin users" }, "message": { "listenBrainzToken": "Enter your ListenBrainz user token.", @@ -183,7 +178,6 @@ }, "player": { "name": "Player |||| Players", - "menuName": "Players & API keys", "fields": { "name": "Name", "transcodingId": "Transcoding", @@ -192,29 +186,7 @@ "userName": "Username", "lastSeen": "Last Seen At", "reportRealPath": "Report Real Path", - "scrobbleEnabled": "Send Scrobbles to external services", - "hasApiKey": "API Key" - }, - "actions": { - "generateApiKey": "Generate API key", - "regenerateApiKey": "Regenerate", - "revokeApiKey": "Revoke", - "copyApiKey": "Copy" - }, - "message": { - "apiKeyActive": "This player has an API key. Use it in your app as the API key, or as the password if the app does not use token authentication.", - "apiKeyNone": "No API key. Generate one to connect an app to this player.", - "apiKeyNoneOther": "No API key.", - "apiKeyPending": "Copy this key now. It is saved when you click Save and will not be shown again.", - "apiKeyRevokePending": "The API key will be removed when you save.", - "deleteWithKeyTitle": "Delete player", - "deleteWithKeyContent": "This player has an API key. Apps using it will stop working." - }, - "notifications": { - "apiKeyCopied": "API key copied to clipboard" - }, - "validation": { - "apiKeyFormat": "Invalid API key format" + "scrobbleEnabled": "Send Scrobbles to external services" } }, "transcoding": { @@ -224,9 +196,6 @@ "targetFormat": "Target Format", "defaultBitRate": "Default Bit Rate", "command": "Command" - }, - "choices": { - "noDefaultBitRate": "None" } }, "playlist": { @@ -241,8 +210,7 @@ "songCount": "Songs", "comment": "Comment", "sync": "Auto-import", - "path": "Import from", - "starred": "Favourite" + "path": "Import from" }, "actions": { "selectPlaylist": "Select a playlist:", @@ -331,22 +299,11 @@ "totalDuration": "Duration", "defaultNewUsers": "Default for New Users", "createdAt": "Created", - "updatedAt": "Updated", - "pidAlbum": "Album grouping", - "pidTrack": "Track identity" + "updatedAt": "Updated" }, "sections": { "basic": "Basic Information", - "statistics": "Statistics", - "pid": "Persistent IDs" - }, - "pid": { - "global": "Use global setting (%{value})", - "folder": "Folder (one album per folder)", - "custom": "Custom", - "spec": "PID spec", - "help": "Tags and attributes that identify an item. See the documentation for the syntax:", - "docs": "Persistent IDs" + "statistics": "Statistics" }, "actions": { "scan": "Scan Library", @@ -376,9 +333,7 @@ "messages": { "deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.", "scanInProgress": "Scan in progress...", - "noLibrariesAssigned": "No libraries assigned to this user", - "pidChangeTitle": "Change persistent IDs?", - "pidChangeConfirm": "This regroups albums and tracks in this library. A full rescan of this library starts now. Track stars, ratings and play counts are kept. Album stars and ratings move to the new albums where an old album maps to a new one." + "noLibrariesAssigned": "No libraries assigned to this user" } }, "plugin": { @@ -437,7 +392,6 @@ "invalidJson": "Configuration must be valid JSON" }, "messages": { - "idHelp": "The plugin ID, derived from its file name. Use it when referencing this plugin in configuration options, such as Agents.", "configHelp": "Configure the plugin using key-value pairs. Leave empty if the plugin requires no configuration.", "configValidationError": "Configuration validation failed:", "schemaRenderError": "Unable to render configuration form. The plugin's schema may be invalid.", @@ -611,7 +565,6 @@ "coverRemoved": "Cover art removed", "coverUploadError": "Error uploading cover art", "coverRemoveError": "Error removing cover art", - "metadataRefreshStarted": "Refreshing metadata in the background", "note": "NOTE", "transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.", "transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.", @@ -628,9 +581,6 @@ "remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.", "notifications_blocked": "You have blocked Notifications for this site in your browser's settings", "notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https", - "quickConnectApproved": "%{app} on %{device} is now signed in", - "quickConnectInvalidCode": "Invalid or expired code", - "quickConnectError": "Could not approve the code", "lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled", "lastfmLinkFailure": "Last.fm could not be linked", "lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled", @@ -662,14 +612,6 @@ "none": "None" }, "settings": "Settings", - "quickConnect": { - "name": "Quick Connect", - "code": "Code", - "help": "Enter the code shown by a Jellyfin app to sign it in to your account", - "confirm": "Sign in %{app} %{version} on %{device} to your account?", - "continue": "Continue", - "approve": "Approve" - }, "version": "Version", "theme": "Theme", "personal": { @@ -693,7 +635,6 @@ }, "albumList": "Albums", "playlists": "Playlists", - "onlyFavourites": "Only show favourites", "sharedPlaylists": "Shared Playlists", "about": "About" }, diff --git a/ui/src/i18n/provider.js b/ui/src/i18n/provider.js index 58f1782bb..f17a5b4ac 100644 --- a/ui/src/i18n/provider.js +++ b/ui/src/i18n/provider.js @@ -42,14 +42,13 @@ const removeEmpty = (obj) => { const prepareLanguage = (lang) => { removeEmpty(lang) - // Aliases below go on the merged copy: mutating `en` would corrupt the completion baseline - const merged = deepmerge(en, lang) // Make "albumSong" and "playlistTrack" resource use the same translations as "song" - merged.resources.albumSong = merged.resources.song - merged.resources.playlistTrack = merged.resources.song + lang.resources.albumSong = lang.resources.song + lang.resources.playlistTrack = lang.resources.song // ra.boolean.null should always be empty - merged.ra.boolean.null = '' - return merged + lang.ra.boolean.null = '' + // Fallback to english translations + return deepmerge(en, lang) } export default polyglotI18nProvider((locale) => { diff --git a/ui/src/i18n/provider.test.js b/ui/src/i18n/provider.test.js deleted file mode 100644 index 82b593788..000000000 --- a/ui/src/i18n/provider.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import en from './en.json' - -vi.mock('../dataProvider', () => ({ default: { getOne: vi.fn() } })) - -const countLeaves = (obj) => - Object.values(obj).reduce( - (sum, v) => - sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), - 0, - ) - -describe('i18n provider', () => { - it('does not mutate the bundled English translations', async () => { - const before = countLeaves(en) - await import('./provider') - expect(countLeaves(en)).toEqual(before) - }) -}) diff --git a/ui/src/i18n/useDateLocale.js b/ui/src/i18n/useDateLocale.js deleted file mode 100644 index c9298a473..000000000 --- a/ui/src/i18n/useDateLocale.js +++ /dev/null @@ -1,14 +0,0 @@ -import { useLocale } from 'react-admin' - -// Our language codes are mostly region-less ("en"), and Intl reads a bare "en" -// as en-US. Borrow the region from the browser when it speaks the same language. -const resolveDateLocale = (locale, browserLocales = []) => { - if (!locale || locale.includes('-')) return locale - const base = locale.toLowerCase() - return ( - browserLocales.find((l) => l.toLowerCase().split('-')[0] === base) || locale - ) -} - -export const useDateLocale = () => - resolveDateLocale(useLocale(), navigator.languages) diff --git a/ui/src/i18n/useDateLocale.test.js b/ui/src/i18n/useDateLocale.test.js deleted file mode 100644 index c0ea5ab40..000000000 --- a/ui/src/i18n/useDateLocale.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { useDateLocale } from './useDateLocale' - -vi.mock('react-admin', () => ({ - useLocale: vi.fn(), -})) - -describe('useDateLocale', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - const renderWith = async (locale, browserLocales) => { - const { useLocale } = await import('react-admin') - vi.mocked(useLocale).mockReturnValue(locale) - vi.spyOn(navigator, 'languages', 'get').mockReturnValue(browserLocales) - return renderHook(() => useDateLocale()).result - } - - it('adds the region from the browser when the language has none', async () => { - const result = await renderWith('en', ['en-GB', 'fr-FR']) - expect(result.current).toEqual('en-GB') - }) - - it('falls back to the language when no browser entry matches', async () => { - const result = await renderWith('de', ['en-US', 'fr-FR']) - expect(result.current).toEqual('de') - }) - - it('keeps a language that already carries a region or script', async () => { - expect((await renderWith('pt-br', ['pt-PT'])).current).toEqual('pt-br') - expect((await renderWith('zh-Hans', ['zh-TW'])).current).toEqual('zh-Hans') - }) - - it('matches the browser language case-insensitively', async () => { - const result = await renderWith('pt', ['PT-PT']) - expect(result.current).toEqual('PT-PT') - }) - - it('returns undefined when there is no language', async () => { - const result = await renderWith(undefined, ['en-GB']) - expect(result.current).toBeUndefined() - }) -}) diff --git a/ui/src/i18n/useGetLanguageChoices.jsx b/ui/src/i18n/useGetLanguageChoices.jsx index 38f95e422..0c708691f 100644 --- a/ui/src/i18n/useGetLanguageChoices.jsx +++ b/ui/src/i18n/useGetLanguageChoices.jsx @@ -1,22 +1,5 @@ // React Hook to get a list of all languages available. English is hardcoded import { useGetList } from 'react-admin' -import en from './en.json' - -const countLeaves = (obj) => - Object.values(obj).reduce( - (sum, v) => - sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), - 0, - ) - -const enTermCount = countLeaves(en) - -const withPercentage = ({ id, name, termCount }) => { - if (!termCount) return { id, name } - const pct = Math.min(100, Math.round((100 * termCount) / enTermCount)) - // Isolate the percentage, or it renders as "(%61)" next to a right-to-left name - return { id, name: `${name} ⁦(${pct}%)⁩` } -} const useGetLanguageChoices = () => { const { ids, data, loaded, loading } = useGetList( @@ -26,19 +9,13 @@ const useGetLanguageChoices = () => { {}, ) - const languages = [{ id: 'en', name: 'English', termCount: enTermCount }] + const choices = [{ id: 'en', name: 'English' }] if (loaded) { - ids.forEach((id) => - languages.push({ - id, - name: data[id].name, - termCount: data[id].termCount, - }), - ) + ids.forEach((id) => choices.push({ id: id, name: data[id].name })) } - languages.sort((a, b) => a.name.localeCompare(b.name)) + choices.sort((a, b) => a.name.localeCompare(b.name)) - return { choices: languages.map(withPercentage), loaded, loading } + return { choices, loaded, loading } } export default useGetLanguageChoices diff --git a/ui/src/i18n/useGetLanguageChoices.test.jsx b/ui/src/i18n/useGetLanguageChoices.test.jsx deleted file mode 100644 index b8214868e..000000000 --- a/ui/src/i18n/useGetLanguageChoices.test.jsx +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { renderHook } from '@testing-library/react-hooks' -import { useGetList } from 'react-admin' -import en from './en.json' -import useGetLanguageChoices from './useGetLanguageChoices' - -vi.mock('react-admin', () => ({ - useGetList: vi.fn(), -})) - -const countLeaves = (obj) => - Object.values(obj).reduce( - (sum, v) => - sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), - 0, - ) -const enTermCount = countLeaves(en) - -// The percentage is wrapped in a left-to-right isolate, so it reads the same -// next to right-to-left language names -const label = (pct) => `⁦(${pct}%)⁩` - -const mockLanguages = (languages) => { - const data = {} - languages.forEach((l) => (data[l.id] = l)) - useGetList.mockReturnValue({ - ids: languages.map((l) => l.id), - data, - loaded: true, - loading: false, - }) -} - -const choiceFor = (id) => { - const { result } = renderHook(() => useGetLanguageChoices()) - return result.current.choices.find((c) => c.id === id) -} - -describe('useGetLanguageChoices', () => { - it('appends the completion percentage to incomplete languages', () => { - const termCount = Math.round(enTermCount * 0.62) - mockLanguages([{ id: 'cs', name: 'Čeština', termCount }]) - - const pct = Math.round((100 * termCount) / enTermCount) - expect(choiceFor('cs').name).toEqual(`Čeština ${label(pct)}`) - }) - - it('shows 100% for a complete language', () => { - mockLanguages([{ id: 'de', name: 'Deutsch', termCount: enTermCount }]) - - expect(choiceFor('de').name).toEqual(`Deutsch ${label(100)}`) - }) - - it('caps the percentage at 100 when a language has extra terms', () => { - mockLanguages([ - { id: 'pt', name: 'Português', termCount: enTermCount + 20 }, - ]) - - expect(choiceFor('pt').name).toEqual(`Português ${label(100)}`) - }) - - it('isolates the percentage next to a right-to-left name', () => { - const termCount = Math.round(enTermCount * 0.61) - mockLanguages([{ id: 'ar', name: 'العربية', termCount }]) - - const pct = Math.round((100 * termCount) / enTermCount) - expect(choiceFor('ar').name).toEqual(`العربية ⁦(${pct}%)⁩`) - }) - - it('omits the percentage when the server does not send a term count', () => { - mockLanguages([{ id: 'fr', name: 'Français' }]) - - expect(choiceFor('fr').name).toEqual('Français') - }) - - it('shows 100% for the bundled English', () => { - mockLanguages([]) - - expect(choiceFor('en').name).toEqual(`English ${label(100)}`) - }) - - it('sorts by language name, ignoring the percentage', () => { - mockLanguages([ - { id: 'no', name: 'Norsk', termCount: enTermCount }, - { id: 'da', name: 'Dansk', termCount: 1 }, - ]) - - const { result } = renderHook(() => useGetLanguageChoices()) - - expect(result.current.choices.map((c) => c.id)).toEqual(['da', 'en', 'no']) - }) -}) diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx index 460d33bb9..561701dce 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.jsx @@ -6,17 +6,12 @@ import { usePermissions, getResources, } from 'react-admin' -import { - MdInfo, - MdPerson, - MdPhonelink, - MdSupervisorAccount, -} from 'react-icons/md' +import { MdInfo, MdPerson, MdSupervisorAccount } from 'react-icons/md' import { useSelector } from 'react-redux' import { makeStyles, MenuItem, ListItemIcon, Divider } from '@material-ui/core' import ViewListIcon from '@material-ui/icons/ViewList' import { Dialogs } from '../dialogs/Dialogs' -import { AboutDialog, QuickConnectDialog } from '../dialogs' +import { AboutDialog } from '../dialogs' import PersonalMenu from './PersonalMenu' import ActivityPanel from './ActivityPanel' import NowPlayingPanel from './NowPlayingPanel' @@ -38,34 +33,33 @@ const useStyles = makeStyles( }, ) -const DialogMenuItem = forwardRef( - ({ onClick, label, icon, dialog, ...rest }, ref) => { - const classes = useStyles(rest) - const [open, setOpen] = React.useState(false) +const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => { + const classes = useStyles(rest) + const translate = useTranslate() + const [open, setOpen] = React.useState(false) - const handleClose = () => { - onClick && onClick() - setOpen(false) - } - return ( - <> - <MenuItem - ref={ref} - onClick={() => setOpen(true)} - className={classes.root} - > - <ListItemIcon className={classes.icon}> - {createElement(icon, { title: label, size: 24 })} - </ListItemIcon> - {label} - </MenuItem> - {createElement(dialog, { onClose: handleClose, open })} - </> - ) - }, -) + const handleOpen = () => { + setOpen(true) + } + const handleClose = () => { + onClick && onClick() + setOpen(false) + } + const label = translate('menu.about') + return ( + <> + <MenuItem ref={ref} onClick={handleOpen} className={classes.root}> + <ListItemIcon className={classes.icon}> + <MdInfo title={label} size={24} /> + </ListItemIcon> + {label} + </MenuItem> + <AboutDialog onClose={handleClose} open={open} /> + </> + ) +}) -DialogMenuItem.displayName = 'DialogMenuItem' +AboutMenuItem.displayName = 'AboutMenuItem' const settingsResources = (resource) => resource.name !== 'user' && @@ -102,11 +96,9 @@ const CustomUserMenu = ({ onClick, ...rest }) => { } const renderSettingsMenuItemLink = (resource, id) => { - const label = resource.options.label - ? translate(resource.options.label) - : translate(`resources.${resource.name}.name`, { - smart_count: id ? 1 : 2, - }) + const label = translate(`resources.${resource.name}.name`, { + smart_count: id ? 1 : 2, + }) const link = id ? `/${resource.name}/${id}` : `/${resource.name}` return ( <MenuItemLink @@ -134,24 +126,13 @@ const CustomUserMenu = ({ onClick, ...rest }) => { {config.devActivityPanel && permissions === 'admin' && <ActivityPanel />} <UserMenu {...rest}> <PersonalMenu sidebarIsOpen={true} onClick={onClick} /> - {config.enableQuickConnect && ( - <DialogMenuItem - label={translate('menu.quickConnect.name')} - icon={MdPhonelink} - dialog={QuickConnectDialog} - /> - )} <Divider /> {renderUserMenuItemLink()} {resources .filter(settingsResources) .map((r) => renderSettingsMenuItemLink(r))} <Divider /> - <DialogMenuItem - label={translate('menu.about')} - icon={MdInfo} - dialog={AboutDialog} - /> + <AboutMenuItem /> </UserMenu> <Dialogs /> </> diff --git a/ui/src/layout/AppBar.test.jsx b/ui/src/layout/AppBar.test.jsx index cc5ef781d..f39dd75cb 100644 --- a/ui/src/layout/AppBar.test.jsx +++ b/ui/src/layout/AppBar.test.jsx @@ -9,14 +9,11 @@ import config from '../config' let store -const mocks = vi.hoisted(() => ({ resources: [] })) - vi.mock('react-admin', () => ({ AppBar: ({ userMenu }) => <div data-testid="appbar">{userMenu}</div>, - MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>, useTranslate: () => (x) => x, usePermissions: () => ({ permissions: 'admin' }), - getResources: () => mocks.resources, + getResources: () => [], })) vi.mock('./NowPlayingPanel', () => ({ @@ -36,15 +33,12 @@ vi.mock('../dialogs/Dialogs', () => ({ })) vi.mock('../dialogs', () => ({ AboutDialog: () => <div />, - QuickConnectDialog: () => <div />, })) describe('<AppBar />', () => { beforeEach(() => { config.devActivityPanel = true config.enableNowPlaying = true - config.enableQuickConnect = false - mocks.resources = [] store = createStore(combineReducers({ activity: activityReducer }), { activity: { nowPlayingCount: 0 }, }) @@ -68,42 +62,4 @@ describe('<AppBar />', () => { ) expect(screen.queryByTestId('now-playing-panel')).toBeNull() }) - - it('shows the Quick Connect menu item when enabled', () => { - config.enableQuickConnect = true - render( - <Provider store={store}> - <AppBar /> - </Provider>, - ) - expect(screen.queryAllByText('menu.quickConnect.name')).not.toHaveLength(0) - }) - - it('hides the Quick Connect menu item when disabled', () => { - render( - <Provider store={store}> - <AppBar /> - </Provider>, - ) - expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0) - expect(screen.queryAllByText('menu.about')).not.toHaveLength(0) - }) - - it('uses the resource label for settings items when set', () => { - mocks.resources = [ - { - name: 'player', - hasList: true, - options: { subMenu: 'settings', label: 'resources.player.menuName' }, - }, - { name: 'transcoding', hasList: true, options: { subMenu: 'settings' } }, - ] - render( - <Provider store={store}> - <AppBar /> - </Provider>, - ) - expect(screen.getByText('resources.player.menuName')).toBeInTheDocument() - expect(screen.getByText('resources.transcoding.name')).toBeInTheDocument() - }) }) diff --git a/ui/src/layout/Login.jsx b/ui/src/layout/Login.jsx index a7763cff3..91f56b273 100644 --- a/ui/src/layout/Login.jsx +++ b/ui/src/layout/Login.jsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react' +import React, { useState, useCallback, useEffect } from 'react' import PropTypes from 'prop-types' import { Field, Form } from 'react-final-form' import { useDispatch } from 'react-redux' @@ -13,6 +13,8 @@ import { createMuiTheme, useLogin, useNotify, + useRefresh, + useSetLocale, useTranslate, useVersion, } from 'react-admin' @@ -22,6 +24,7 @@ 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( @@ -98,13 +101,8 @@ const renderInput = ({ }) => ( <TextField error={!!(touched && error)} - inputProps={{ - // mobile keyboards: suppress capitalization and correction for login related fields - autocapitalize: 'none', - autocorrect: 'off', - ...inputProps, - }} helperText={touched && error} + {...inputProps} {...props} fullWidth /> @@ -404,8 +402,27 @@ 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 ( <ThemeProvider theme={createMuiTheme(theme)}> <Login key={version} {...props} /> diff --git a/ui/src/layout/Notification.jsx b/ui/src/layout/Notification.jsx index ff641ec22..001d3fc01 100644 --- a/ui/src/layout/Notification.jsx +++ b/ui/src/layout/Notification.jsx @@ -1,26 +1,11 @@ import React from 'react' import { Notification as RANotification } from 'react-admin' -import { makeStyles } from '@material-ui/core/styles' -// RA's primary.light Undo is unreadable on the light snackbar of dark themes -const useStyles = makeStyles( - { - undo: { - color: 'inherit', - }, - }, - { name: 'NDNotification' }, +const Notification = (props) => ( + <RANotification + {...props} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + /> ) -const Notification = (props) => { - const classes = useStyles() - return ( - <RANotification - {...props} - classes={{ undo: classes.undo }} - anchorOrigin={{ vertical: 'top', horizontal: 'center' }} - /> - ) -} - export default Notification diff --git a/ui/src/layout/PlaylistsSubMenu.jsx b/ui/src/layout/PlaylistsSubMenu.jsx index f332f6810..b94bebf86 100644 --- a/ui/src/layout/PlaylistsSubMenu.jsx +++ b/ui/src/layout/PlaylistsSubMenu.jsx @@ -1,24 +1,19 @@ -import React, { useCallback, useMemo, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import React, { useCallback } from 'react' import { MenuItemLink, useDataProvider, useNotify, useQueryWithStore, - useTranslate, } from 'react-admin' import { useHistory } from 'react-router-dom' import QueueMusicIcon from '@material-ui/icons/QueueMusic' import { Typography } from '@material-ui/core' import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined' -import FavoriteIcon from '@material-ui/icons/Favorite' -import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' -import { BiListUl } from 'react-icons/bi' +import { BiCog } from 'react-icons/bi' import { useDrop } from 'react-dnd' import SubMenu from './SubMenu' -import { canChangeTracks, OverflowTooltip, useRefreshOnEvents } from '../common' +import { canChangeTracks, OverflowTooltip } from '../common' import { DraggableTypes } from '../consts' -import { setSidebarPlaylistsOnlyFavourites } from '../actions' import config from '../config' const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { @@ -58,37 +53,6 @@ const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { const history = useHistory() - const dispatch = useDispatch() - const translate = useTranslate() - const onlyFavourites = useSelector( - (state) => state.settings.sidebarPlaylistsOnlyFavourites, - ) - // Ignore a persisted preference when the feature is off, so disabling it later - // (with the toggle now hidden) doesn't strand the user on a filtered sidebar - const showFavouritesOnly = config.enableFavourites && onlyFavourites - const playlistData = useSelector( - (state) => state.admin.resources.playlist?.data, - ) - // Fingerprint of local star state; changes only when a playlist is (un)starred, - // so a local toggle refetches the sidebar without the SSE echo the actor never gets - const starFingerprint = useMemo(() => { - const data = playlistData || {} - return Object.keys(data) - .filter((id) => data[id]?.starred) - .sort() - .join(',') - }, [playlistData]) - const [refreshCount, setRefreshCount] = useState(0) - - // Only the favourites-only view depends on star state changing elsewhere; - // when showing all playlists a star event from another client changes nothing - // async because useRefreshOnEvents calls .catch() on the returned value - const onRefresh = useCallback(async () => { - if (showFavouritesOnly) setRefreshCount((count) => count + 1) - }, [showFavouritesOnly]) - useRefreshOnEvents({ events: ['playlist'], onRefresh }) - - // A changed payload signature makes useQueryWithStore refetch const { data, loaded } = useQueryWithStore({ type: 'getList', resource: 'playlist', @@ -98,11 +62,6 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { perPage: config.maxSidebarPlaylists, }, sort: { field: 'name' }, - ...(showFavouritesOnly && { - filter: { starred: true }, - starFingerprint, - refresh: refreshCount, - }), }, }) @@ -139,10 +98,6 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { [history], ) - const handleToggleFavourites = useCallback(() => { - dispatch(setSidebarPlaylistsOnlyFavourites(!onlyFavourites)) - }, [dispatch, onlyFavourites]) - return ( <> <SubMenu @@ -152,20 +107,8 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { name={'menu.playlists'} icon={<QueueMusicIcon />} dense={dense} - actionIcon={<BiListUl />} + actionIcon={<BiCog />} onAction={onPlaylistConfig} - onSecondaryAction={ - config.enableFavourites ? handleToggleFavourites : undefined - } - secondaryActionIcon={ - onlyFavourites ? ( - <FavoriteIcon fontSize={'small'} /> - ) : ( - <FavoriteBorderIcon fontSize={'small'} /> - ) - } - secondaryActionTitle={translate('menu.onlyFavourites')} - secondaryActionActive={onlyFavourites} > {myPlaylists.map(renderPlaylistMenuItemLink)} </SubMenu> diff --git a/ui/src/layout/PlaylistsSubMenu.test.jsx b/ui/src/layout/PlaylistsSubMenu.test.jsx deleted file mode 100644 index 617f60a4e..000000000 --- a/ui/src/layout/PlaylistsSubMenu.test.jsx +++ /dev/null @@ -1,180 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, act } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { Provider } from 'react-redux' -import { createStore, combineReducers } from 'redux' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' -import { settingsReducer, activityReducer } from '../reducers' -import { processEvent, EVENT_REFRESH_RESOURCE } from '../actions' -import PlaylistsSubMenu from './PlaylistsSubMenu' - -const mockUseQueryWithStore = vi.fn() - -vi.mock('../config', () => ({ - // losslessFormats is read at module-load time by common/QualityInfo.jsx, - // pulled in transitively via the '../common' barrel file - default: { - enableFavourites: true, - maxSidebarPlaylists: 100, - losslessFormats: '', - }, -})) - -vi.mock('react-dnd', () => ({ - useDrop: () => [{}, () => {}], -})) - -vi.mock('react-router-dom', () => ({ - useHistory: () => ({ push: vi.fn() }), -})) - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useTranslate: () => (x) => x, - useDataProvider: () => ({ addToPlaylist: vi.fn() }), - useNotify: () => vi.fn(), - useQueryWithStore: (query) => mockUseQueryWithStore(query), - MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>, - } -}) - -const playlists = { - 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' }, - 'pl-2': { id: 'pl-2', name: 'Theirs', ownerId: 'user-2' }, -} - -const SET_PLAYLIST_DATA = 'TEST/SET_PLAYLIST_DATA' -const adminReducer = (state = { resources: {} }, action) => - action.type === SET_PLAYLIST_DATA - ? { resources: { playlist: { data: action.data } } } - : state - -const renderMenu = (preloadedSettings = {}, preloadedPlaylistData) => { - const store = createStore( - combineReducers({ - settings: settingsReducer, - activity: activityReducer, - admin: adminReducer, - }), - { - settings: preloadedSettings, - activity: {}, - admin: { - resources: preloadedPlaylistData - ? { playlist: { data: preloadedPlaylistData } } - : {}, - }, - }, - ) - const theme = createTheme() - render( - <Provider store={store}> - <ThemeProvider theme={theme}> - <PlaylistsSubMenu - state={{ menuPlaylists: true, menuSharedPlaylists: true }} - setState={vi.fn()} - sidebarIsOpen={true} - dense={false} - /> - </ThemeProvider> - </Provider>, - ) - return store -} - -const lastQuery = () => - mockUseQueryWithStore.mock.calls[ - mockUseQueryWithStore.mock.calls.length - 1 - ][0] - -describe('<PlaylistsSubMenu />', () => { - beforeEach(() => { - vi.clearAllMocks() - localStorage.setItem('userId', 'user-1') - mockUseQueryWithStore.mockReturnValue({ data: playlists, loaded: true }) - // SubMenu uses MUI's useMediaQuery, which needs window.matchMedia in jsdom - window.matchMedia = (query) => ({ - matches: false, - media: query, - addListener: () => {}, - removeListener: () => {}, - }) - // OverflowTooltip (via MenuItemLink) needs ResizeObserver, unavailable in jsdom - window.ResizeObserver = class { - observe() {} - unobserve() {} - disconnect() {} - } - }) - - it('queries without a starred filter by default', () => { - renderMenu() - expect(lastQuery().payload.filter).toBeUndefined() - expect(screen.getByText('Mine')).not.toBeNull() - expect(screen.getByText('Theirs')).not.toBeNull() - }) - - it('adds the starred filter when favourites-only is enabled', () => { - renderMenu({ sidebarPlaylistsOnlyFavourites: true }) - expect(lastQuery().payload.filter).toEqual({ starred: true }) - }) - - it('toggles the setting when the heart action is clicked', () => { - const store = renderMenu() - fireEvent.click(screen.getByTitle('menu.onlyFavourites')) - expect(store.getState().settings.sidebarPlaylistsOnlyFavourites).toBe(true) - expect(lastQuery().payload.filter).toEqual({ starred: true }) - }) - - it('refetches on a playlist SSE event when favourites-only is on', async () => { - const store = renderMenu({ sidebarPlaylistsOnlyFavourites: true }) - const before = lastQuery().payload.refresh - // useRefreshOnEvents compares Date.now() timestamps; make sure it advances - await act(() => new Promise((resolve) => setTimeout(resolve, 5))) - act(() => { - store.dispatch( - processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }), - ) - }) - expect(lastQuery().payload.refresh).toBe(before + 1) - }) - - it('does not change the query signature on an SSE event when favourites-only is off', async () => { - const store = renderMenu() - const before = JSON.stringify(lastQuery().payload) - await act(() => new Promise((resolve) => setTimeout(resolve, 5))) - act(() => { - store.dispatch( - processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }), - ) - }) - // Signature unchanged → useQueryWithStore dedupes, no wasted refetch - expect(lastQuery().payload.refresh).toBeUndefined() - expect(JSON.stringify(lastQuery().payload)).toBe(before) - }) - - it('refetches when a playlist is starred locally (no SSE echo)', () => { - const store = renderMenu( - { sidebarPlaylistsOnlyFavourites: true }, - { 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' } }, - ) - const before = lastQuery().payload.starFingerprint - act(() => { - store.dispatch({ - type: SET_PLAYLIST_DATA, - data: { - 'pl-1': { - id: 'pl-1', - name: 'Mine', - ownerId: 'user-1', - starred: true, - }, - }, - }) - }) - expect(lastQuery().payload.starFingerprint).not.toBe(before) - expect(lastQuery().payload.starFingerprint).toContain('pl-1') - }) -}) diff --git a/ui/src/layout/SubMenu.jsx b/ui/src/layout/SubMenu.jsx index ee1bf343e..418f4c651 100644 --- a/ui/src/layout/SubMenu.jsx +++ b/ui/src/layout/SubMenu.jsx @@ -33,9 +33,6 @@ const useStyles = makeStyles( menuHeader: { width: '100%', }, - headerText: { - flexGrow: 1, - }, headerWrapper: { display: 'flex', '&:hover $actionIcon': { @@ -58,10 +55,6 @@ const SubMenu = ({ dense, onAction, actionIcon, - onSecondaryAction, - secondaryActionIcon, - secondaryActionTitle, - secondaryActionActive, }) => { const translate = useTranslate() const classes = useStyles() @@ -77,11 +70,6 @@ const SubMenu = ({ } } - const handleSecondaryClick = (e) => { - e.stopPropagation() - onSecondaryAction(e) - } - const header = ( <div className={classes.headerWrapper}> <MenuItem @@ -93,26 +81,9 @@ const SubMenu = ({ <ListItemIcon className={classes.icon}> {isOpen ? <ExpandMore /> : icon} </ListItemIcon> - <Typography - variant="inherit" - color="textSecondary" - className={classes.headerText} - > + <Typography variant="inherit" color="textSecondary"> {translate(name)} </Typography> - {onSecondaryAction && sidebarIsOpen && ( - <IconButton - size={'small'} - title={secondaryActionTitle} - aria-label={secondaryActionTitle} - className={ - isDesktop && !secondaryActionActive ? classes.actionIcon : null - } - onClick={handleSecondaryClick} - > - {secondaryActionIcon} - </IconButton> - )} {onAction && sidebarIsOpen && ( <IconButton size={'small'} diff --git a/ui/src/library/LibraryCreate.jsx b/ui/src/library/LibraryCreate.jsx index 8166bb2f3..0e69964b6 100644 --- a/ui/src/library/LibraryCreate.jsx +++ b/ui/src/library/LibraryCreate.jsx @@ -1,5 +1,4 @@ import React, { useCallback } from 'react' -import PropTypes from 'prop-types' import { Create, SimpleForm, @@ -11,34 +10,7 @@ import { useNotify, useRedirect, } from 'react-admin' -import { Typography } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' import { Title } from '../common' -import { PIDInputs } from './PIDInput' - -const useStyles = makeStyles((theme) => ({ - spaced: { marginTop: theme.spacing(3) }, -})) - -// SimpleForm passes form props (variant, record, ...) to its children, so Typography can't be used directly -const SectionTitle = ({ label, spaced }) => { - const translate = useTranslate() - const classes = useStyles() - return ( - <Typography - variant="h6" - gutterBottom - className={spaced ? classes.spaced : undefined} - > - {translate(label)} - </Typography> - ) -} - -SectionTitle.propTypes = { - label: PropTypes.string.isRequired, - spaced: PropTypes.bool, -} const LibraryCreate = (props) => { const translate = useTranslate() @@ -101,12 +73,9 @@ const LibraryCreate = (props) => { return ( <Create title={<Title subTitle={title} />} {...props}> <SimpleForm save={save} variant={'outlined'}> - <SectionTitle label="resources.library.sections.basic" /> <TextInput source="name" validate={[required()]} /> <TextInput source="path" validate={[required()]} fullWidth /> <BooleanInput source="defaultNewUsers" /> - <SectionTitle label="resources.library.sections.pid" spaced /> - <PIDInputs /> </SimpleForm> </Create> ) diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx index c42c7ac4b..7e89c892c 100644 --- a/ui/src/library/LibraryEdit.jsx +++ b/ui/src/library/LibraryEdit.jsx @@ -1,13 +1,12 @@ -import React, { useCallback, useState } from 'react' -import PropTypes from 'prop-types' +import React, { useCallback } from 'react' import { Edit, FormWithRedirect, TextInput, BooleanInput, - Confirm, required, SaveButton, + DateField, useTranslate, useMutation, useNotify, @@ -17,16 +16,8 @@ import { import { Typography, Box } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import DeleteLibraryButton from './DeleteLibraryButton' -import { - ReadOnlyDateField, - ReadOnlyDurationField, - ReadOnlyNumberField, - ReadOnlySizeField, - Title, -} from '../common' -import config from '../config' -import { PIDInputs } from './PIDInput' -import { pidConfigChanged } from './pidPresets' +import { Title } from '../common' +import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js' const useStyles = makeStyles({ toolbar: { @@ -35,8 +26,6 @@ const useStyles = makeStyles({ }, }) -const readOnlyProps = { resource: 'library', fullWidth: true } - const LibraryTitle = ({ record }) => { const translate = useTranslate() const resourceName = translate('resources.library.name', { smart_count: 1 }) @@ -58,131 +47,8 @@ const CustomToolbar = ({ showDelete, ...props }) => ( </Toolbar> ) -export const LibraryEditForm = ({ formProps, canEditPath, canDelete }) => { - const translate = useTranslate() - const [confirmOpen, setConfirmOpen] = useState(false) - - // Every submit path (Save button and Enter key) goes through here, so a PID change always asks first - const submit = () => { - if ( - pidConfigChanged( - formProps.form.getState().values, - formProps.record, - config, - ) - ) { - setConfirmOpen(true) - return - } - formProps.handleSubmit() - } - - const handleConfirm = () => { - setConfirmOpen(false) - formProps.handleSubmit() - } - - return ( - <form - onSubmit={(event) => { - event.preventDefault() - submit() - }} - > - <Box p="1em" maxWidth="800px"> - <Box display="flex"> - <Box flex={1} mr="1em"> - {/* Basic Information */} - <Typography variant="h6" gutterBottom> - {translate('resources.library.sections.basic')} - </Typography> - - <TextInput - source="name" - label={translate('resources.library.fields.name')} - validate={[required()]} - variant="outlined" - /> - <TextInput - source="path" - label={translate('resources.library.fields.path')} - validate={[required()]} - fullWidth - variant="outlined" - InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1 - /> - <BooleanInput - source="defaultNewUsers" - label={translate('resources.library.fields.defaultNewUsers')} - variant="outlined" - /> - - <Box mt="2em" /> - <Typography variant="h6" gutterBottom> - {translate('resources.library.sections.pid')} - </Typography> - <PIDInputs /> - - <Box mt="2em" /> - - {/* Statistics - Two Column Layout */} - <Typography variant="h6" gutterBottom> - {translate('resources.library.sections.statistics')} - </Typography> - - <Box - display="grid" - gridTemplateColumns="1fr 1fr" - gridColumnGap="1em" - > - <ReadOnlyNumberField source="totalSongs" {...readOnlyProps} /> - <ReadOnlyNumberField source="totalAlbums" {...readOnlyProps} /> - <ReadOnlyNumberField source="totalArtists" {...readOnlyProps} /> - <ReadOnlySizeField source="totalSize" {...readOnlyProps} /> - <ReadOnlyDurationField - source="totalDuration" - {...readOnlyProps} - /> - <ReadOnlyNumberField - source="totalMissingFiles" - {...readOnlyProps} - /> - <Box gridColumn="1 / -1"> - <ReadOnlyDateField source="lastScanAt" {...readOnlyProps} /> - </Box> - <ReadOnlyDateField source="updatedAt" {...readOnlyProps} /> - <ReadOnlyDateField source="createdAt" {...readOnlyProps} /> - </Box> - </Box> - </Box> - </Box> - - <CustomToolbar - handleSubmitWithRedirect={submit} - pristine={formProps.pristine} - saving={formProps.saving} - record={formProps.record} - showDelete={canDelete} - /> - <Confirm - isOpen={confirmOpen} - loading={formProps.saving} - title="resources.library.messages.pidChangeTitle" - content="resources.library.messages.pidChangeConfirm" - onConfirm={handleConfirm} - onClose={() => setConfirmOpen(false)} - /> - </form> - ) -} - -LibraryEditForm.propTypes = { - formProps: PropTypes.object.isRequired, - canEditPath: PropTypes.bool, - canDelete: PropTypes.bool, -} - const LibraryEdit = (props) => { + const translate = useTranslate() const [mutate] = useMutation() const notify = useNotify() const redirect = useRedirect() @@ -221,11 +87,183 @@ const LibraryEdit = (props) => { {...props} save={save} render={(formProps) => ( - <LibraryEditForm - formProps={formProps} - canEditPath={canEditPath} - canDelete={canDelete} - /> + <form onSubmit={formProps.handleSubmit}> + <Box p="1em" maxWidth="800px"> + <Box display="flex"> + <Box flex={1} mr="1em"> + {/* Basic Information */} + <Typography variant="h6" gutterBottom> + {translate('resources.library.sections.basic')} + </Typography> + + <TextInput + source="name" + label={translate('resources.library.fields.name')} + validate={[required()]} + variant="outlined" + /> + <TextInput + source="path" + label={translate('resources.library.fields.path')} + validate={[required()]} + fullWidth + variant="outlined" + InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1 + /> + <BooleanInput + source="defaultNewUsers" + label={translate( + 'resources.library.fields.defaultNewUsers', + )} + variant="outlined" + /> + + <Box mt="2em" /> + + {/* Statistics - Two Column Layout */} + <Typography variant="h6" gutterBottom> + {translate('resources.library.sections.statistics')} + </Typography> + + <Box display="flex"> + <Box flex={1} mr="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalSongs'} + label={translate('resources.library.fields.totalSongs')} + fullWidth + variant="outlined" + /> + </Box> + <Box flex={1} ml="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalAlbums'} + label={translate( + 'resources.library.fields.totalAlbums', + )} + fullWidth + variant="outlined" + /> + </Box> + </Box> + + <Box display="flex"> + <Box flex={1} mr="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalArtists'} + label={translate( + 'resources.library.fields.totalArtists', + )} + fullWidth + variant="outlined" + /> + </Box> + <Box flex={1} ml="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalSize'} + label={translate('resources.library.fields.totalSize')} + format={(v) => formatBytes(v, 2)} + fullWidth + variant="outlined" + /> + </Box> + </Box> + + <Box display="flex"> + <Box flex={1} mr="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalDuration'} + label={translate( + 'resources.library.fields.totalDuration', + )} + format={formatDuration2} + fullWidth + variant="outlined" + /> + </Box> + <Box flex={1} ml="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalMissingFiles'} + label={translate( + 'resources.library.fields.totalMissingFiles', + )} + fullWidth + variant="outlined" + /> + </Box> + </Box> + + {/* Timestamps Section */} + <Box mb="1em"> + <Typography + variant="body2" + color="textSecondary" + gutterBottom + > + {translate('resources.library.fields.lastScanAt')} + </Typography> + <DateField + variant="body1" + source="lastScanAt" + showTime + record={formProps.record} + /> + </Box> + + <Box mb="1em"> + <Typography + variant="body2" + color="textSecondary" + gutterBottom + > + {translate('resources.library.fields.updatedAt')} + </Typography> + <DateField + variant="body1" + source="updatedAt" + showTime + record={formProps.record} + /> + </Box> + + <Box mb="2em"> + <Typography + variant="body2" + color="textSecondary" + gutterBottom + > + {translate('resources.library.fields.createdAt')} + </Typography> + <DateField + variant="body1" + source="createdAt" + showTime + record={formProps.record} + /> + </Box> + </Box> + </Box> + </Box> + + <CustomToolbar + handleSubmitWithRedirect={formProps.handleSubmitWithRedirect} + pristine={formProps.pristine} + saving={formProps.saving} + record={formProps.record} + showDelete={canDelete} + /> + </form> )} /> </Edit> diff --git a/ui/src/library/LibraryEdit.test.jsx b/ui/src/library/LibraryEdit.test.jsx deleted file mode 100644 index 926adc839..000000000 --- a/ui/src/library/LibraryEdit.test.jsx +++ /dev/null @@ -1,125 +0,0 @@ -import * as React from 'react' -import { TestContext } from 'ra-test' -import { - FormWithRedirect, - RecordContextProvider, - SaveContextProvider, -} from 'react-admin' -import { - cleanup, - fireEvent, - render, - screen, - waitFor, - within, -} from '@testing-library/react' -import { describe, it, expect, vi, afterEach } from 'vitest' -import { LibraryEditForm } from './LibraryEdit' -import config from '../config' - -const record = { - id: '2', - name: 'Jazz', - path: '/music/jazz', - pidAlbum: '', - pidTrack: '', -} - -// Edit provides a save context in the app. SaveButton only reads these setters from it -const saveContext = { - save: vi.fn(), - setOnSuccess: vi.fn(), - setOnFailure: vi.fn(), - setTransform: vi.fn(), -} - -const renderForm = (save) => - render( - <TestContext> - <SaveContextProvider value={saveContext}> - <RecordContextProvider value={record}> - <FormWithRedirect - record={record} - save={save} - render={(formProps) => ( - <LibraryEditForm - formProps={formProps} - canEditPath - canDelete={false} - /> - )} - /> - </RecordContextProvider> - </SaveContextProvider> - </TestContext>, - ) - -const chooseAlbumGrouping = (optionText) => { - fireEvent.mouseDown( - screen.getByLabelText('resources.library.fields.pidAlbum'), - ) - fireEvent.click(within(screen.getByRole('listbox')).getByText(optionText)) -} - -const dialogTitle = 'resources.library.messages.pidChangeTitle' - -describe('LibraryEditForm', () => { - afterEach(cleanup) - - it('saves directly when the PID config did not change', async () => { - const save = vi.fn() - renderForm(save) - fireEvent.change(screen.getByLabelText(/resources.library.fields.name/), { - target: { value: 'Jazz Renamed' }, - }) - fireEvent.click(screen.getByText('ra.action.save')) - await waitFor(() => expect(save).toHaveBeenCalled()) - expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument() - }) - - it('asks before saving a PID change, and Cancel keeps the edits', async () => { - const save = vi.fn() - renderForm(save) - chooseAlbumGrouping('resources.library.pid.folder') - fireEvent.click(screen.getByText('ra.action.save')) - - expect(await screen.findByText(dialogTitle)).toBeInTheDocument() - expect(save).not.toHaveBeenCalled() - - fireEvent.click(screen.getByText('ra.action.cancel')) - await waitFor(() => - expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument(), - ) - expect(save).not.toHaveBeenCalled() - expect(screen.getByText('resources.library.pid.folder')).toBeInTheDocument() - }) - - it('saves the PID change after Confirm', async () => { - const save = vi.fn() - renderForm(save) - chooseAlbumGrouping('resources.library.pid.folder') - fireEvent.click(screen.getByText('ra.action.save')) - fireEvent.click(await screen.findByText('ra.action.confirm')) - - await waitFor(() => expect(save).toHaveBeenCalled()) - expect(save.mock.calls[0][0]).toMatchObject({ pidAlbum: 'folder' }) - }) - - it('pre-fills a Custom spec with the global spec', () => { - renderForm(vi.fn()) - chooseAlbumGrouping('resources.library.pid.custom') - expect(screen.getByLabelText(/resources.library.pid.spec/)).toHaveValue( - config.pidAlbum, - ) - }) - - it('asks before saving when the form is submitted with Enter', async () => { - const save = vi.fn() - const { container } = renderForm(save) - chooseAlbumGrouping('resources.library.pid.folder') - fireEvent.submit(container.querySelector('form')) - - expect(await screen.findByText(dialogTitle)).toBeInTheDocument() - expect(save).not.toHaveBeenCalled() - }) -}) diff --git a/ui/src/library/LibraryScanButton.jsx b/ui/src/library/LibraryScanButton.jsx index b793844fd..50d90e615 100644 --- a/ui/src/library/LibraryScanButton.jsx +++ b/ui/src/library/LibraryScanButton.jsx @@ -8,8 +8,8 @@ import { useUnselectAll, } from 'react-admin' import { useSelector } from 'react-redux' -import { GiMagnifyingGlass } from 'react-icons/gi' -import { VscSync } from 'react-icons/vsc' +import SyncIcon from '@material-ui/icons/Sync' +import CachedIcon from '@material-ui/icons/Cached' import subsonic from '../subsonic' const LibraryScanButton = ({ fullScan, selectedIds, className }) => { @@ -54,7 +54,7 @@ const LibraryScanButton = ({ fullScan, selectedIds, className }) => { ? translate('resources.library.actions.fullScan') : translate('resources.library.actions.quickScan') - const icon = fullScan ? <GiMagnifyingGlass /> : <VscSync /> + const icon = fullScan ? <CachedIcon /> : <SyncIcon /> return ( <Button diff --git a/ui/src/library/PIDInput.jsx b/ui/src/library/PIDInput.jsx deleted file mode 100644 index 6481dc392..000000000 --- a/ui/src/library/PIDInput.jsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useState } from 'react' -import PropTypes from 'prop-types' -import { TextInput, required, useTranslate } from 'react-admin' -import { useField } from 'react-final-form' -import { FormHelperText, Link, MenuItem, TextField } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import { - PID_CUSTOM, - PID_FOLDER, - PID_GLOBAL, - pidModeFromValue, - pidValueForMode, -} from './pidPresets' -import config from '../config' -import { docsUrl } from '../utils' - -const PID_DOCS_URL = docsUrl('/docs/usage/pids/') - -const useStyles = makeStyles((theme) => ({ - help: { marginBottom: theme.spacing(1) }, -})) - -// PIDInput edits a library PID override: use the global setting, a preset, or a custom spec -export const PIDInput = ({ source, label, globalValue, allowFolder }) => { - const translate = useTranslate() - const classes = useStyles() - const { input } = useField(source) - // Local state, so choosing Custom shows the text box before anything is typed - const [mode, setMode] = useState(() => - pidModeFromValue(input.value, allowFolder), - ) - - const choices = [ - { - id: PID_GLOBAL, - name: translate('resources.library.pid.global', { value: globalValue }), - }, - ...(allowFolder - ? [{ id: PID_FOLDER, name: translate('resources.library.pid.folder') }] - : []), - { id: PID_CUSTOM, name: translate('resources.library.pid.custom') }, - ] - - const handleModeChange = (event) => { - const newMode = event.target.value - setMode(newMode) - input.onChange(pidValueForMode(newMode, globalValue)) - } - - return ( - <> - <TextField - id={`${source}-mode`} - select - fullWidth - variant="outlined" - margin="dense" - label={label} - value={mode} - onChange={handleModeChange} - > - {choices.map((choice) => ( - <MenuItem key={choice.id} value={choice.id}> - {choice.name} - </MenuItem> - ))} - </TextField> - {mode === PID_CUSTOM && ( - <> - <TextInput - source={source} - label={translate('resources.library.pid.spec')} - validate={[required()]} - fullWidth - variant="outlined" - helperText={false} - /> - <FormHelperText className={classes.help}> - {translate('resources.library.pid.help')}{' '} - <Link href={PID_DOCS_URL} target="_blank" rel="noopener noreferrer"> - {translate('resources.library.pid.docs')} - </Link> - </FormHelperText> - </> - )} - </> - ) -} - -PIDInput.propTypes = { - source: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - globalValue: PropTypes.string, - allowFolder: PropTypes.bool, -} - -export const PIDInputs = () => { - const translate = useTranslate() - return ( - <> - <PIDInput - source="pidAlbum" - label={translate('resources.library.fields.pidAlbum')} - globalValue={config.pidAlbum} - allowFolder - /> - <PIDInput - source="pidTrack" - label={translate('resources.library.fields.pidTrack')} - globalValue={config.pidTrack} - /> - </> - ) -} diff --git a/ui/src/library/pidPresets.js b/ui/src/library/pidPresets.js deleted file mode 100644 index 0483fc691..000000000 --- a/ui/src/library/pidPresets.js +++ /dev/null @@ -1,33 +0,0 @@ -export const PID_GLOBAL = 'global' -export const PID_FOLDER = 'folder' -export const PID_CUSTOM = 'custom' - -export const pidModeFromValue = (value, allowFolder) => { - const v = (value || '').trim() - if (v === '') return PID_GLOBAL - if (allowFolder && v === PID_FOLDER) return PID_FOLDER - return PID_CUSTOM -} - -// Returns the value to store for a dropdown choice. Custom starts from the global spec -export const pidValueForMode = (mode, globalValue) => { - switch (mode) { - case PID_GLOBAL: - return '' - case PID_FOLDER: - return PID_FOLDER - default: - return globalValue || '' - } -} - -// Reports whether the form values change the effective PID spec of the saved record. Like the -// server, it trims, treats empty as the global value and compares case-insensitively -export const pidConfigChanged = (values, record, globals) => { - const effective = (value, field) => - ((value || '').trim() || globals[field] || '').toLowerCase() - return ['pidAlbum', 'pidTrack'].some( - (field) => - effective(values[field], field) !== effective(record[field], field), - ) -} diff --git a/ui/src/library/pidPresets.test.js b/ui/src/library/pidPresets.test.js deleted file mode 100644 index dab1c82e2..000000000 --- a/ui/src/library/pidPresets.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - PID_CUSTOM, - PID_FOLDER, - PID_GLOBAL, - pidConfigChanged, - pidModeFromValue, - pidValueForMode, -} from './pidPresets' - -describe('pidModeFromValue', () => { - it('maps an empty value to the global setting', () => { - expect(pidModeFromValue('', true)).toBe(PID_GLOBAL) - expect(pidModeFromValue(undefined, true)).toBe(PID_GLOBAL) - }) - it('maps folder to the Folder preset when allowed', () => { - expect(pidModeFromValue('folder', true)).toBe(PID_FOLDER) - }) - it('maps folder to Custom when the Folder preset is not offered', () => { - expect(pidModeFromValue('folder', false)).toBe(PID_CUSTOM) - }) - it('maps any other value to Custom', () => { - expect(pidModeFromValue('album|title', true)).toBe(PID_CUSTOM) - }) -}) - -describe('pidValueForMode', () => { - it('stores an empty value for the global setting', () => { - expect(pidValueForMode(PID_GLOBAL, 'album')).toBe('') - }) - it('stores folder for the Folder preset', () => { - expect(pidValueForMode(PID_FOLDER, '')).toBe('folder') - }) - it('starts Custom from the global spec', () => { - expect(pidValueForMode(PID_CUSTOM, 'album|title')).toBe('album|title') - expect(pidValueForMode(PID_CUSTOM, undefined)).toBe('') - }) -}) - -describe('pidConfigChanged', () => { - const record = { pidAlbum: 'folder', pidTrack: '' } - const globals = { - pidAlbum: 'musicbrainz_albumid|albumartistid,album', - pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', - } - it.each([ - ['nothing changed', { pidAlbum: 'folder', pidTrack: '' }, false], - ['a missing value equals an empty one', { pidAlbum: 'folder' }, false], - [ - 'Custom set to the global value', - { pidAlbum: 'folder', pidTrack: globals.pidTrack }, - false, - ], - ['a case-only change', { pidAlbum: 'FOLDER', pidTrack: '' }, false], - [ - 'a whitespace-only change', - { pidAlbum: ' folder ', pidTrack: ' ' }, - false, - ], - ['the album PID changed', { pidAlbum: '', pidTrack: '' }, true], - ['the track PID changed', { pidAlbum: 'folder', pidTrack: 'title' }, true], - ])('%s', (_, values, expected) => { - expect(pidConfigChanged(values, record, globals)).toBe(expected) - }) -}) diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx index a09f552d9..87d9f629f 100644 --- a/ui/src/missing/MissingFilesList.jsx +++ b/ui/src/missing/MissingFilesList.jsx @@ -1,15 +1,10 @@ -import { - List, - Pagination, - SizeField, - getStoredPerPage, - useResourceRefresh, -} from '../common/index' +import { List, SizeField, useResourceRefresh } from '../common/index' import { Datagrid, DateField, TextField, downloadCSV, + Pagination, Filter, ReferenceInput, useTranslate, @@ -54,10 +49,8 @@ const BulkActionButtons = (props) => ( </> ) -const missingPerPageOptions = [50, 100, 200] - const MissingPagination = (props) => ( - <Pagination rowsPerPageOptions={missingPerPageOptions} {...props} /> + <Pagination rowsPerPageOptions={[50, 100, 200]} {...props} /> ) const MissingFilesList = (props) => { @@ -70,7 +63,7 @@ const MissingFilesList = (props) => { actions={<MissingListActions />} filters={<MissingFilesFilter />} bulkActionButtons={<BulkActionButtons />} - perPage={getStoredPerPage('missing', missingPerPageOptions)} + perPage={50} pagination={<MissingPagination />} > <Datagrid> diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.jsx index c8e07328f..67018d2bb 100644 --- a/ui/src/personal/LastfmScrobbleToggle.jsx +++ b/ui/src/personal/LastfmScrobbleToggle.jsx @@ -13,10 +13,21 @@ import { baseUrl, openInNewTab } from '../utils' import { httpClient } from '../dataProvider' const Progress = (props) => { - const { setLinked, setCheckingLink, openedTab } = props + const { setLinked, setCheckingLink, apiKey } = 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 @@ -65,7 +76,6 @@ 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') @@ -78,42 +88,9 @@ 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) { - startLink() + setCheckingLink(true) } else { httpClient('/api/lastfm/link', { method: 'DELETE' }) .then(() => { @@ -144,7 +121,7 @@ export const LastfmScrobbleToggle = (props) => { <Progress setLinked={setLinked} setCheckingLink={setCheckingLink} - openedTab={openedTab} + apiKey={apiKey} /> )} {!apiKey && ( diff --git a/ui/src/personal/SelectDefaultView.jsx b/ui/src/personal/SelectDefaultView.jsx index e90fd65bc..71c87305c 100644 --- a/ui/src/personal/SelectDefaultView.jsx +++ b/ui/src/personal/SelectDefaultView.jsx @@ -1,10 +1,13 @@ import { SelectInput, useTranslate } from 'react-admin' -import { getDefaultViewChoices, getStoredDefaultView } from './defaultViews' +import albumLists, { defaultAlbumList } from '../album/albumLists' export const SelectDefaultView = (props) => { const translate = useTranslate() - const current = getStoredDefaultView() - const choices = getDefaultViewChoices(translate) + const current = localStorage.getItem('defaultView') || defaultAlbumList + const choices = Object.keys(albumLists).map((type) => ({ + id: type, + name: translate(`resources.album.lists.${type}`), + })) return ( <SelectInput diff --git a/ui/src/personal/defaultViews.js b/ui/src/personal/defaultViews.js deleted file mode 100644 index a5a2c074d..000000000 --- a/ui/src/personal/defaultViews.js +++ /dev/null @@ -1,20 +0,0 @@ -import albumLists, { defaultAlbumList } from '../album/albumLists' - -export const resourceDefaultViews = ['artist', 'song', 'playlist', 'radio'] - -export const isResourceDefaultView = (defaultView) => - 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 deleted file mode 100644 index 4e80b2340..000000000 --- a/ui/src/personal/defaultViews.test.js +++ /dev/null @@ -1,50 +0,0 @@ -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' }, - { id: 'radio', name: 'resources.radio.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('radio')).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/player/ApiKeyInput.jsx b/ui/src/player/ApiKeyInput.jsx deleted file mode 100644 index 1f2dd1ed2..000000000 --- a/ui/src/player/ApiKeyInput.jsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { useInput, useNotify, useTranslate } from 'react-admin' -import { Button, TextField } from '@material-ui/core' -import { FaKey } from 'react-icons/fa' -import { MdContentCopy, MdDelete, MdRefresh } from 'react-icons/md' -import { isWritable } from '../common/playlistUtils' -import { generateApiKey } from './apiKey' - -const identity = (v) => v -const MASK = '•'.repeat(26) - -const ApiKeyInput = ({ record, isCreate, fullWidth, className, ...props }) => { - const translate = useTranslate() - const notify = useNotify() - // Identity format/parse keep "" (revoke) distinct from undefined (untouched) - const { - input: { value, onChange }, - meta: { error, touched }, - } = useInput({ ...props, format: identity, parse: identity }) - - const isOwner = isCreate || record?.userId === localStorage.getItem('userId') - const pending = !!value - const revoking = value === '' && !!record?.hasApiKey - const saved = value == null && !!record?.hasApiKey - const hasKey = pending || saved - - const copy = () => { - const fallback = () => - prompt(translate('message.shareCopyToClipboard'), value) - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard - .writeText(value) - .then( - () => notify('resources.player.notifications.apiKeyCopied'), - fallback, - ) - } else { - fallback() - } - } - - const helperText = pending - ? 'resources.player.message.apiKeyPending' - : revoking - ? 'resources.player.message.apiKeyRevokePending' - : saved - ? 'resources.player.message.apiKeyActive' - : isOwner - ? 'resources.player.message.apiKeyNone' - : 'resources.player.message.apiKeyNoneOther' - - return ( - <div> - <TextField - label={translate('resources.player.fields.hasApiKey')} - value={pending ? value : saved ? MASK : ''} - variant="outlined" - margin="dense" - fullWidth={fullWidth} - className={className} - // Monospace keeps the whole key visible in a standard-width input - inputProps={{ - style: { fontFamily: 'monospace', fontSize: '0.875rem' }, - }} - InputProps={{ readOnly: true }} - error={!!(touched && error)} - helperText={translate(touched && error ? error : helperText)} - /> - <div> - {pending && ( - <Button startIcon={<MdContentCopy />} onClick={copy}> - {translate('resources.player.actions.copyApiKey')} - </Button> - )} - {isOwner && ( - <Button - startIcon={hasKey ? <MdRefresh /> : <FaKey />} - onClick={() => onChange(generateApiKey())} - > - {translate( - hasKey - ? 'resources.player.actions.regenerateApiKey' - : 'resources.player.actions.generateApiKey', - )} - </Button> - )} - {saved && isWritable(record?.userId) && ( - <Button startIcon={<MdDelete />} onClick={() => onChange('')}> - {translate('resources.player.actions.revokeApiKey')} - </Button> - )} - </div> - </div> - ) -} - -ApiKeyInput.propTypes = { - source: PropTypes.string.isRequired, - record: PropTypes.object, - isCreate: PropTypes.bool, - fullWidth: PropTypes.bool, - className: PropTypes.string, - validate: PropTypes.oneOfType([PropTypes.func, PropTypes.array]), -} - -export default ApiKeyInput diff --git a/ui/src/player/ApiKeyInput.test.jsx b/ui/src/player/ApiKeyInput.test.jsx deleted file mode 100644 index dd4269d6e..000000000 --- a/ui/src/player/ApiKeyInput.test.jsx +++ /dev/null @@ -1,172 +0,0 @@ -import * as React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { Form } from 'react-final-form' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import ApiKeyInput from './ApiKeyInput' - -const hooks = vi.hoisted(() => ({ notify: vi.fn() })) -const KEY = 'nds_0123456789abcdefghijkl' -const KEY_FORMAT = /^nds_[0-9A-Za-z]{22}$/ - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - useNotify: () => hooks.notify, - useTranslate: () => (key) => key, - } -}) - -const renderInput = ({ - record, - initialValues = {}, - isCreate = false, - fullWidth, -}) => { - let values - const utils = render( - <Form - onSubmit={() => {}} - initialValues={initialValues} - render={({ values: v }) => { - values = v - return ( - <ApiKeyInput - source="apiKey" - record={record} - isCreate={isCreate} - fullWidth={fullWidth} - /> - ) - }} - />, - ) - return { ...utils, values: () => values } -} - -const text = (key) => screen.queryByText(key) - -describe('ApiKeyInput', () => { - beforeEach(() => { - vi.clearAllMocks() - localStorage.setItem('userId', 'owner') - localStorage.setItem('role', 'regular') - }) - - it('shows a pending key with copy and regenerate on create', () => { - const { values } = renderInput({ - record: {}, - isCreate: true, - initialValues: { apiKey: KEY }, - }) - expect(screen.getByDisplayValue(KEY)).toBeInTheDocument() - expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument() - expect( - screen.getByRole('button', { - name: 'resources.player.actions.copyApiKey', - }), - ).toBeInTheDocument() - expect( - text('resources.player.actions.revokeApiKey'), - ).not.toBeInTheDocument() - - fireEvent.click( - screen.getByText('resources.player.actions.regenerateApiKey'), - ) - expect(values().apiKey).toMatch(KEY_FORMAT) - expect(values().apiKey).not.toBe(KEY) - }) - - it('masks a saved key and lets the owner regenerate or revoke', () => { - const { values } = renderInput({ - record: { id: 'p1', userId: 'owner', hasApiKey: true }, - }) - expect(screen.queryByDisplayValue(/^nds_/)).not.toBeInTheDocument() - expect(text('resources.player.message.apiKeyActive')).toBeInTheDocument() - expect( - text('resources.player.actions.regenerateApiKey'), - ).toBeInTheDocument() - - fireEvent.click(screen.getByText('resources.player.actions.revokeApiKey')) - expect(values().apiKey).toBe('') - expect( - text('resources.player.message.apiKeyRevokePending'), - ).toBeInTheDocument() - expect(text('resources.player.actions.generateApiKey')).toBeInTheDocument() - }) - - it('lets the owner generate a key when there is none', () => { - const { values } = renderInput({ - record: { id: 'p1', userId: 'owner', hasApiKey: false }, - }) - expect(values().apiKey).toBeUndefined() - expect(text('resources.player.message.apiKeyNone')).toBeInTheDocument() - - fireEvent.click(screen.getByText('resources.player.actions.generateApiKey')) - expect(values().apiKey).toMatch(KEY_FORMAT) - expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument() - }) - - it('lets an admin revoke but not set a key on another user player', () => { - localStorage.setItem('role', 'admin') - renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } }) - expect( - text('resources.player.actions.regenerateApiKey'), - ).not.toBeInTheDocument() - expect(text('resources.player.actions.revokeApiKey')).toBeInTheDocument() - }) - - it('falls back to a prompt when the clipboard write fails', async () => { - vi.stubGlobal('isSecureContext', true) - vi.stubGlobal('prompt', vi.fn()) - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, - }) - try { - renderInput({ - record: {}, - isCreate: true, - initialValues: { apiKey: KEY }, - }) - fireEvent.click( - screen.getByRole('button', { - name: 'resources.player.actions.copyApiKey', - }), - ) - await waitFor(() => - expect(window.prompt).toHaveBeenCalledWith( - 'message.shareCopyToClipboard', - KEY, - ), - ) - expect(hooks.notify).not.toHaveBeenCalled() - } finally { - vi.unstubAllGlobals() - delete navigator.clipboard - } - }) - - it('shows a neutral message to an admin viewing another user player with no key', () => { - localStorage.setItem('role', 'admin') - renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: false } }) - expect(text('resources.player.message.apiKeyNoneOther')).toBeInTheDocument() - expect(text('resources.player.message.apiKeyNone')).not.toBeInTheDocument() - expect(screen.queryAllByRole('button')).toHaveLength(0) - }) - - it('shows no actions to another regular user', () => { - renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } }) - expect(screen.queryAllByRole('button')).toHaveLength(0) - }) - - it('is not full width unless asked', () => { - const record = { id: 'p1', userId: 'owner', hasApiKey: true } - const { container, unmount } = renderInput({ record }) - expect(container.querySelector('.MuiFormControl-fullWidth')).toBeNull() - unmount() - - const { container: wide } = renderInput({ record, fullWidth: true }) - expect(wide.querySelector('.MuiFormControl-fullWidth')).not.toBeNull() - }) -}) diff --git a/ui/src/player/PlayerCreate.jsx b/ui/src/player/PlayerCreate.jsx deleted file mode 100644 index bf02cde1b..000000000 --- a/ui/src/player/PlayerCreate.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { useMemo } from 'react' -import { Create, SimpleForm, required, useTranslate } from 'react-admin' -import { Title } from '../common' -import { playerInputs } from './playerInputs' -import ApiKeyInput from './ApiKeyInput' -import { generateApiKey } from './apiKey' - -const PlayerCreateTitle = () => { - const translate = useTranslate() - const resourceName = translate('resources.player.name', { smart_count: 1 }) - return ( - <Title subTitle={translate('ra.page.create', { name: resourceName })} /> - ) -} - -const PlayerCreate = (props) => { - // Memoized so re-renders don't swap the key the user may have already copied - const initialValues = useMemo(() => ({ apiKey: generateApiKey() }), []) - return ( - <Create title={<PlayerCreateTitle />} {...props}> - <SimpleForm - variant="outlined" - redirect="list" - initialValues={initialValues} - > - {playerInputs()} - <ApiKeyInput source="apiKey" isCreate validate={required()} /> - </SimpleForm> - </Create> - ) -} - -export default PlayerCreate diff --git a/ui/src/player/PlayerCreate.test.jsx b/ui/src/player/PlayerCreate.test.jsx deleted file mode 100644 index dc1f817db..000000000 --- a/ui/src/player/PlayerCreate.test.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from 'react' -import { render } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import PlayerCreate from './PlayerCreate' -import ApiKeyInput from './ApiKeyInput' - -const hooks = vi.hoisted(() => ({ forms: [] })) - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - Create: ({ children }) => children, - SimpleForm: (props) => { - hooks.forms.push(props) - return null - }, - } -}) - -describe('PlayerCreate', () => { - beforeEach(() => { - hooks.forms = [] - }) - - it('pre-fills one generated API key that survives re-renders', () => { - const { rerender } = render(<PlayerCreate resource="player" />) - rerender(<PlayerCreate resource="player" />) - - const [first, second] = hooks.forms.map((f) => f.initialValues) - expect(hooks.forms).toHaveLength(2) - expect(first.apiKey).toMatch(/^nds_[0-9A-Za-z]{22}$/) - expect(second).toBe(first) - }) - - it('requires the API key', () => { - render(<PlayerCreate resource="player" />) - - const input = React.Children.toArray(hooks.forms[0].children).find( - (child) => child.type === ApiKeyInput, - ) - expect(input.props.source).toBe('apiKey') - expect(input.props.isCreate).toBe(true) - expect(input.props.validate('')).toBeTruthy() - expect(input.props.validate('nds_0123456789abcdefghijkl')).toBeUndefined() - }) -}) diff --git a/ui/src/player/PlayerEdit.jsx b/ui/src/player/PlayerEdit.jsx index d785eb04e..1826500bd 100644 --- a/ui/src/player/PlayerEdit.jsx +++ b/ui/src/player/PlayerEdit.jsx @@ -1,16 +1,17 @@ import { + TextInput, + BooleanInput, + TextField, Edit, + required, SimpleForm, + SelectInput, + ReferenceInput, useTranslate, - DeleteButton, - DeleteWithConfirmButton, - SaveButton, - Toolbar, } from 'react-admin' -import { makeStyles } from '@material-ui/core/styles' -import { ReadOnlyTextField, Title } from '../common' -import ApiKeyInput from './ApiKeyInput' -import { playerInputs } from './playerInputs' +import { Title } from '../common' +import config from '../config' +import { BITRATE_CHOICES } from '../consts' const PlayerTitle = ({ record }) => { const translate = useTranslate() @@ -18,35 +19,24 @@ const PlayerTitle = ({ record }) => { return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> } -const useToolbarStyles = makeStyles({ - toolbar: { - display: 'flex', - justifyContent: 'space-between', - }, -}) - -const PlayerEditToolbar = (props) => ( - <Toolbar {...props} classes={useToolbarStyles()}> - <SaveButton /> - {props.record?.hasApiKey ? ( - <DeleteWithConfirmButton - mutationMode="pessimistic" - confirmTitle="resources.player.message.deleteWithKeyTitle" - confirmContent="resources.player.message.deleteWithKeyContent" - /> - ) : ( - <DeleteButton /> - )} - </Toolbar> -) - const PlayerEdit = (props) => ( - <Edit title={<PlayerTitle />} mutationMode="pessimistic" {...props}> - <SimpleForm variant={'outlined'} toolbar={<PlayerEditToolbar />}> - {playerInputs()} - <ReadOnlyTextField source="client" /> - <ReadOnlyTextField source="userName" /> - <ApiKeyInput source="apiKey" /> + <Edit title={<PlayerTitle />} {...props}> + <SimpleForm variant={'outlined'}> + <TextInput source="name" validate={[required()]} /> + <ReferenceInput + source="transcodingId" + reference="transcoding" + sort={{ field: 'name', order: 'ASC' }} + > + <SelectInput source="name" resettable /> + </ReferenceInput> + <SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} /> + <BooleanInput source="reportRealPath" fullWidth /> + {(config.lastFMEnabled || config.listenBrainzEnabled) && ( + <BooleanInput source="scrobbleEnabled" fullWidth /> + )} + <TextField source="client" /> + <TextField source="userName" /> </SimpleForm> </Edit> ) diff --git a/ui/src/player/PlayerEdit.test.jsx b/ui/src/player/PlayerEdit.test.jsx deleted file mode 100644 index 38fa2cc8e..000000000 --- a/ui/src/player/PlayerEdit.test.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from 'react' -import { render } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' -import PlayerEdit from './PlayerEdit' - -const hooks = vi.hoisted(() => ({ editProps: null })) - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - Edit: (props) => { - hooks.editProps = props - return null - }, - } -}) - -describe('PlayerEdit', () => { - // An optimistic or undoable save would put the new key in react-admin's cache - it('saves pessimistically', () => { - render(<PlayerEdit resource="player" id="p1" />) - expect(hooks.editProps.mutationMode).toBe('pessimistic') - }) -}) diff --git a/ui/src/player/PlayerList.jsx b/ui/src/player/PlayerList.jsx index c7bd32570..a2b009bad 100644 --- a/ui/src/player/PlayerList.jsx +++ b/ui/src/player/PlayerList.jsx @@ -2,20 +2,18 @@ import React from 'react' import { Datagrid, TextField, + DateField, FunctionField, ReferenceField, Filter, SearchInput, - NullableBooleanInput, } from 'react-admin' import { useMediaQuery } from '@material-ui/core' -import { FaKey } from 'react-icons/fa' -import { SimpleList, List, DateField } from '../common' +import { SimpleList, List } from '../common' const PlayerFilter = (props) => ( <Filter {...props} variant={'outlined'}> <SearchInput id="search" source="name" alwaysOn /> - <NullableBooleanInput source="hasApiKey" alwaysOn /> </Filter> ) @@ -32,11 +30,7 @@ const PlayerList = ({ permissions, ...props }) => { <SimpleList primaryText={(r) => r.name} secondaryText={(r) => r.userName} - tertiaryText={(r) => ( - <> - {r.hasApiKey && <FaKey />} {r.maxBitRate ? r.maxBitRate : '-'} - </> - )} + tertiaryText={(r) => (r.maxBitRate ? r.maxBitRate : '-')} /> ) : ( <Datagrid rowClick="edit"> @@ -49,11 +43,6 @@ const PlayerList = ({ permissions, ...props }) => { source="maxBitRate" render={(r) => (r.maxBitRate ? r.maxBitRate : '-')} /> - <FunctionField - source="hasApiKey" - sortable={false} - render={(r) => (r.hasApiKey ? <FaKey /> : null)} - /> <DateField source="lastSeen" showTime sortByOrder={'DESC'} /> </Datagrid> )} diff --git a/ui/src/player/apiKey.js b/ui/src/player/apiKey.js deleted file mode 100644 index 27d683052..000000000 --- a/ui/src/player/apiKey.js +++ /dev/null @@ -1,15 +0,0 @@ -const API_KEY_PREFIX = 'nds_' -const ALPHABET = - '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' -const KEY_LENGTH = 22 - -// Bytes >= 248 are dropped so every character is equally likely (248 = 4 * 62). -export const generateApiKey = () => { - let key = '' - while (key.length < KEY_LENGTH) { - for (const b of window.crypto.getRandomValues(new Uint8Array(32))) { - if (b < 248 && key.length < KEY_LENGTH) key += ALPHABET[b % 62] - } - } - return API_KEY_PREFIX + key -} diff --git a/ui/src/player/apiKey.test.js b/ui/src/player/apiKey.test.js deleted file mode 100644 index 4c41871e7..000000000 --- a/ui/src/player/apiKey.test.js +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { generateApiKey } from './apiKey' - -describe('generateApiKey', () => { - it('returns the prefix plus 22 base62 characters', () => { - for (let i = 0; i < 50; i++) { - expect(generateApiKey()).toMatch(/^nds_[0-9A-Za-z]{22}$/) - } - }) - - it('returns a different key each time', () => { - const keys = new Set(Array.from({ length: 100 }, generateApiKey)) - expect(keys.size).toBe(100) - }) -}) diff --git a/ui/src/player/index.js b/ui/src/player/index.js index c28cfa414..aaa3d58d7 100644 --- a/ui/src/player/index.js +++ b/ui/src/player/index.js @@ -1,11 +1,9 @@ import { BsFillMusicPlayerFill } from 'react-icons/bs' import PlayerList from './PlayerList' import PlayerEdit from './PlayerEdit' -import PlayerCreate from './PlayerCreate' export default { list: PlayerList, edit: PlayerEdit, - create: PlayerCreate, icon: BsFillMusicPlayerFill, } diff --git a/ui/src/player/playerInputs.jsx b/ui/src/player/playerInputs.jsx deleted file mode 100644 index a8b719a81..000000000 --- a/ui/src/player/playerInputs.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' -import { - BooleanInput, - ReferenceInput, - SelectInput, - TextInput, - required, -} from 'react-admin' -import config from '../config' -import { BITRATE_CHOICES } from '../consts' - -// Returned as an array, not a component, so SimpleForm still injects its props into each input. -export const playerInputs = () => - [ - <TextInput key="name" source="name" validate={[required()]} />, - <ReferenceInput - key="transcodingId" - source="transcodingId" - reference="transcoding" - sort={{ field: 'name', order: 'ASC' }} - > - <SelectInput source="name" resettable /> - </ReferenceInput>, - <SelectInput - key="maxBitRate" - source="maxBitRate" - resettable - choices={BITRATE_CHOICES} - />, - <BooleanInput key="reportRealPath" source="reportRealPath" fullWidth />, - (config.lastFMEnabled || config.listenBrainzEnabled) && ( - <BooleanInput key="scrobbleEnabled" source="scrobbleEnabled" fullWidth /> - ), - ].filter(Boolean) diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index 5d0f6631e..c396cbbeb 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -1,5 +1,10 @@ -import { useState } from 'react' -import { Card, CardContent, Typography, useMediaQuery } from '@material-ui/core' +import { + Card, + CardContent, + CardMedia, + Typography, + useMediaQuery, +} from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { useTranslate } from 'react-admin' import Lightbox from 'react-image-lightbox' @@ -8,13 +13,13 @@ import { CollapsibleComment, DurationField, ImageUploadOverlay, - LoveButton, SizeField, isWritable, OverflowTooltip, + useImageLoadingState, } from '../common' +import config from '../config' import subsonic from '../subsonic' -import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -69,19 +74,13 @@ const useStyles = makeStyles( backgroundColor: 'transparent', transition: 'opacity 0.3s ease-in-out', }, + coverLoading: { + opacity: 0.5, + }, title: { overflow: 'hidden', textOverflow: 'ellipsis', wordBreak: 'break-word', - minWidth: 0, - }, - titleRow: { - display: 'flex', - alignItems: 'center', - }, - loveButton: { - marginLeft: theme.spacing(0.5), - flexShrink: 0, }, stats: { marginTop: '1em', @@ -98,21 +97,37 @@ const PlaylistDetails = (props) => { const translate = useTranslate() const classes = useStyles() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) - const [isLightboxOpen, setLightboxOpen] = useState(false) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useImageLoadingState(record.id) + const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) const fullImageUrl = subsonic.getCoverArtUrl(record) return ( <Card className={classes.root}> <div className={classes.cardContents}> <div className={classes.coverParent}> - <Artwork - record={record} - square - fit="contain" - className={classes.cover} + <CardMedia + key={record.id} // Force re-render when playlist changes + component={'img'} + src={imageUrl} + width="400" + height="400" + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} + onClick={handleOpenLightbox} + onLoad={handleImageLoad} + onError={handleImageError} title={record.name} - onClick={() => setLightboxOpen(true)} + style={{ + cursor: imageError ? 'default' : 'pointer', + }} /> {isWritable(record.ownerId) && ( <ImageUploadOverlay @@ -124,24 +139,14 @@ const PlaylistDetails = (props) => { </div> <div className={classes.details}> <CardContent className={classes.content}> - <div className={classes.titleRow}> - <OverflowTooltip title={record.name || ''}> - <Typography - variant={isDesktop ? 'h5' : 'h6'} - className={classes.title} - > - {record.name || translate('ra.page.loading')} - </Typography> - </OverflowTooltip> - <LoveButton - className={classes.loveButton} - record={record} - resource={'playlist'} - size={isDesktop ? 'default' : 'small'} - aria-label="love" - color="primary" - /> - </div> + <OverflowTooltip title={record.name || ''}> + <Typography + variant={isDesktop ? 'h5' : 'h6'} + className={classes.title} + > + {record.name || translate('ra.page.loading')} + </Typography> + </OverflowTooltip> <Typography component="p" className={classes.stats}> {record.songCount ? ( <span> @@ -162,13 +167,13 @@ const PlaylistDetails = (props) => { </CardContent> </div> </div> - {isLightboxOpen && ( + {isLightboxOpen && !imageError && ( <Lightbox imagePadding={50} animationDuration={200} imageTitle={record.name} mainSrc={fullImageUrl} - onCloseRequest={() => setLightboxOpen(false)} + onCloseRequest={handleCloseLightbox} /> )} </Card> diff --git a/ui/src/playlist/PlaylistEdit.jsx b/ui/src/playlist/PlaylistEdit.jsx index c7594fa13..f6882e366 100644 --- a/ui/src/playlist/PlaylistEdit.jsx +++ b/ui/src/playlist/PlaylistEdit.jsx @@ -3,6 +3,7 @@ import { FormDataConsumer, SimpleForm, TextInput, + TextField, BooleanInput, required, useTranslate, @@ -10,14 +11,13 @@ import { ReferenceInput, SelectInput, } from 'react-admin' -import { isWritable, ReadOnlyTextField, Title } from '../common' +import { isWritable, Title } from '../common' const SyncFragment = ({ formData, variant, ...rest }) => { - if (!formData.path) return null return ( <> - <BooleanInput source="sync" {...rest} /> - <ReadOnlyTextField source="path" {...rest} /> + {formData.path && <BooleanInput source="sync" {...rest} />} + {formData.path && <TextField source="path" {...rest} />} </> ) } @@ -56,10 +56,10 @@ const PlaylistEditForm = (props) => { /> </ReferenceInput> ) : ( - <ReadOnlyTextField source="ownerName" /> + <TextField source="ownerName" /> )} <BooleanInput source="public" disabled={!isWritable(record.ownerId)} /> - <FormDataConsumer fullWidth> + <FormDataConsumer> {(formDataProps) => <SyncFragment {...formDataProps} />} </FormDataConsumer> </SimpleForm> diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index e1695e980..8732725bc 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -4,7 +4,6 @@ import { DateField, EditButton, Filter, - NullableBooleanInput, NumberField, ReferenceInput, SearchInput, @@ -20,17 +19,14 @@ import Switch from '@material-ui/core/Switch' import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery } from '@material-ui/core' import { - ArtworkAvatar, + CoverArtAvatar, DurationField, List, - LoveButton, Writable, isWritable, useSelectedFields, useResourceRefresh, } from '../common' -import FavoriteIcon from '@material-ui/icons/Favorite' -import config from '../config' import PlaylistListActions from './PlaylistListActions' import ChangePublicStatusButton from './ChangePublicStatusButton' @@ -57,25 +53,19 @@ const PlaylistFilter = (props) => { <SelectInput optionText="name" /> </ReferenceInput> )} - {config.enableFavourites && ( - <NullableBooleanInput - source="starred" - label={<FavoriteIcon fontSize={'small'} />} - /> - )} </Filter> ) } -export const ToggleField = ({ resource, source }) => { +const TogglePublicInput = ({ resource, source }) => { const record = useRecordContext() const notify = useNotify() - const [toggle] = useUpdate( + const [togglePublic] = useUpdate( resource, - record?.id, + record.id, { ...record, - [source]: !record?.[source], + public: !record.public, }, { undoable: false, @@ -86,25 +76,48 @@ export const ToggleField = ({ resource, source }) => { ) const handleClick = (e) => { - toggle() + togglePublic() e.stopPropagation() } - if (!record) return null - return ( <Switch checked={record[source]} - color="primary" onClick={handleClick} disabled={!isWritable(record.ownerId)} /> ) } -export const ToggleAutoImport = (props) => { +const ToggleAutoImport = ({ resource, source }) => { const record = useRecordContext() - return record?.path ? <ToggleField {...props} /> : null + const notify = useNotify() + const [ToggleAutoImport] = useUpdate( + resource, + record.id, + { + ...record, + sync: !record.sync, + }, + { + undoable: false, + onFailure: (error) => { + notify('ra.page.error', 'warning') + }, + }, + ) + const handleClick = (e) => { + ToggleAutoImport() + e.stopPropagation() + } + + return record.path ? ( + <Switch + checked={record[source]} + onClick={handleClick} + disabled={!isWritable(record.ownerId)} + /> + ) : null } const PlaylistListBulkActions = (props) => { @@ -126,13 +139,6 @@ const PlaylistListBulkActions = (props) => { ) } -// Datagrid reads `source`/`sortable`/`label` off this element for the column -// header; only record/resource are forwarded so they never leak onto the button. -export const PlaylistLove = ({ record, className }) => ( - <LoveButton record={record} resource={'playlist'} className={className} /> -) -PlaylistLove.defaultProps = { source: 'starred', sortable: false } - const PlaylistList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) @@ -146,12 +152,13 @@ const PlaylistList = (props) => { updatedAt: isDesktop && ( <DateField source="updatedAt" sortByOrder={'DESC'} /> ), - public: !isXsmall && <ToggleField source="public" sortByOrder={'DESC'} />, + public: !isXsmall && ( + <TogglePublicInput source="public" sortByOrder={'DESC'} /> + ), comment: <TextField source="comment" />, sync: !isXsmall && ( <ToggleAutoImport source="sync" sortByOrder={'DESC'} /> ), - starred: config.enableFavourites && <PlaylistLove />, }), [isDesktop, isXsmall], ) @@ -172,7 +179,7 @@ const PlaylistList = (props) => { bulkActionButtons={!isXsmall && <PlaylistListBulkActions />} > <Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}> - <ArtworkAvatar source="id" variant="square" /> + <CoverArtAvatar source="id" variant="square" /> <TextField source="name" /> {columns} <Writable> diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx deleted file mode 100644 index c05833166..000000000 --- a/ui/src/playlist/PlaylistList.test.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' -import { TestContext } from 'ra-test' -import { RecordContextProvider } from 'react-admin' -import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList' - -vi.mock('../config', () => ({ - default: { enableFavourites: true }, -})) - -vi.mock('../common', () => ({ - LoveButton: ({ record, resource }) => ( - <button data-testid="love" data-resource={resource}> - {record?.starred ? 'starred' : 'not-starred'} - </button> - ), - isWritable: (ownerId) => ownerId === 'me', -})) - -describe('<PlaylistLove />', () => { - it('renders a LoveButton bound to the playlist resource', () => { - render(<PlaylistLove record={{ id: 'pl-1', starred: true }} />) - const btn = screen.getByTestId('love') - expect(btn.getAttribute('data-resource')).toBe('playlist') - expect(btn.textContent).toBe('starred') - }) - - it('exposes datagrid header props so the column renders unsorted', () => { - // The Datagrid reads these off the element; the wrapper body must not - // forward them to the button (which would leak onto the DOM). - expect(PlaylistLove.defaultProps).toEqual({ - source: 'starred', - sortable: false, - }) - }) -}) - -// react-admin evicts records older than 10 minutes while the list still holds -// their ids, so rows can render with no record. -describe('playlist toggles without a record', () => { - it('<ToggleField /> renders nothing', () => { - const { container } = render( - <TestContext> - <ToggleField resource="playlist" source="public" /> - </TestContext>, - ) - expect(container.innerHTML).toBe('') - }) - - it('<ToggleAutoImport /> renders nothing', () => { - const { container } = render( - <TestContext> - <ToggleAutoImport resource="playlist" source="sync" /> - </TestContext>, - ) - expect(container.innerHTML).toBe('') - }) -}) - -// Secondary is a surface color in many themes, so these toggles must use primary -describe('<ToggleField />', () => { - const renderToggle = (record) => - render( - <TestContext> - <RecordContextProvider value={record}> - <ToggleField resource="playlist" source="public" /> - </RecordContextProvider> - </TestContext>, - ) - - it.each([ - ['owner', 'me', false], - ['non-owner', 'someone-else', true], - ])('renders a primary-colored switch for the %s', (_, ownerId, disabled) => { - renderToggle({ id: 'pl-1', public: true, ownerId }) - const input = screen.getByRole('checkbox') - const switchBase = input.closest('.MuiSwitch-switchBase') - expect(input.checked).toBe(true) - expect(input.disabled).toBe(disabled) - expect(switchBase.classList).toContain('MuiSwitch-colorPrimary') - expect(switchBase.classList).not.toContain('MuiSwitch-colorSecondary') - }) -}) diff --git a/ui/src/playlist/PlaylistShow.jsx b/ui/src/playlist/PlaylistShow.jsx index 4c2fea315..f0cb472b1 100644 --- a/ui/src/playlist/PlaylistShow.jsx +++ b/ui/src/playlist/PlaylistShow.jsx @@ -4,21 +4,14 @@ import { ShowContextProvider, useShowContext, useShowController, + Pagination, Title as RaTitle, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import PlaylistDetails from './PlaylistDetails' import PlaylistSongs from './PlaylistSongs' import PlaylistActions from './PlaylistActions' -import { - Pagination, - Title, - canChangeTracks, - getStoredPerPage, - useResourceRefresh, -} from '../common' - -const playlistTrackPerPageOptions = [100, 250, 500] +import { Title, canChangeTracks, useResourceRefresh } from '../common' const useStyles = makeStyles( (theme) => ({ @@ -35,7 +28,7 @@ const PlaylistShowLayout = (props) => { const { loading, ...context } = useShowContext(props) const { record } = context const classes = useStyles() - useResourceRefresh('playlistTrack', 'song') + useResourceRefresh('song') return ( <> @@ -48,10 +41,7 @@ const PlaylistShowLayout = (props) => { reference="playlistTrack" target="playlist_id" sort={{ field: 'id', order: 'ASC' }} - perPage={getStoredPerPage( - 'playlistTrack', - playlistTrackPerPageOptions, - )} + perPage={100} filter={{ playlist_id: props.id }} > <PlaylistSongs @@ -66,9 +56,7 @@ const PlaylistShowLayout = (props) => { } resource={'playlistTrack'} exporter={false} - pagination={ - <Pagination rowsPerPageOptions={playlistTrackPerPageOptions} /> - } + pagination={<Pagination rowsPerPageOptions={[100, 250, 500]} />} /> </ReferenceManyField> )} diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.jsx index 4718a7cf4..bbe38b4d5 100644 --- a/ui/src/playlist/PlaylistSongs.jsx +++ b/ui/src/playlist/PlaylistSongs.jsx @@ -100,8 +100,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { const dataProvider = useDataProvider() const notify = useNotify() const version = useVersion() - // The rows are stored as playlistTrack, not song: their ids are playlist entries. - useResourceRefresh('playlistTrack', 'song', 'playlist') + useResourceRefresh('song', 'playlist') useEffect(() => { setPage(1) diff --git a/ui/src/plugin/InfoCard.jsx b/ui/src/plugin/InfoCard.jsx index 3a7bb24c7..8fb6853fe 100644 --- a/ui/src/plugin/InfoCard.jsx +++ b/ui/src/plugin/InfoCard.jsx @@ -123,13 +123,6 @@ export const InfoCard = ({ record, manifest, classes, translate, isSmall }) => ( isSmall={isSmall} > {record.id} - <Typography - variant="caption" - color="textSecondary" - className={classes.fieldHelp} - > - {translate('resources.plugin.messages.idHelp')} - </Typography> </InfoRow> {manifest?.name && ( @@ -208,7 +201,7 @@ export const InfoCard = ({ record, manifest, classes, translate, isSmall }) => ( <Typography variant="caption" color="textSecondary" - className={classes.fieldHelp} + style={{ marginTop: 4, display: 'block' }} > {translate('resources.plugin.messages.clickPermissions')} </Typography> diff --git a/ui/src/plugin/InfoCard.test.jsx b/ui/src/plugin/InfoCard.test.jsx deleted file mode 100644 index 24fa48caa..000000000 --- a/ui/src/plugin/InfoCard.test.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' - -vi.mock('../common', () => ({ - DateField: ({ source }) => <span data-testid={`date-${source}`} />, -})) - -const { InfoCard } = await import('./InfoCard') - -const record = { - id: 'apple-music', - path: '/data/plugins/apple-music.ndp', - updatedAt: '2026-01-01T00:00:00Z', - createdAt: '2026-01-01T00:00:00Z', -} - -const renderCard = () => - render( - <InfoCard - record={record} - manifest={{ name: 'Apple Music Metadata Agent' }} - classes={{}} - translate={(key) => key} - isSmall={false} - />, - ) - -describe('InfoCard', () => { - it('shows the plugin ID', () => { - renderCard() - expect(screen.getByText('apple-music')).toBeInTheDocument() - }) - - it('explains that the ID is the name used in config options', () => { - renderCard() - expect( - screen.getByText('resources.plugin.messages.idHelp'), - ).toBeInTheDocument() - }) -}) diff --git a/ui/src/plugin/styles.js b/ui/src/plugin/styles.js index 68c0be929..104d8bc0f 100644 --- a/ui/src/plugin/styles.js +++ b/ui/src/plugin/styles.js @@ -45,10 +45,6 @@ export const usePluginShowStyles = makeStyles( fontSize: '0.85rem', wordBreak: 'break-all', }, - fieldHelp: { - marginTop: theme.spacing(0.5), - display: 'block', - }, permissionsContainer: { display: 'flex', flexWrap: 'wrap', diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index af879deaa..bbe001e6f 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -1,4 +1,5 @@ import { + DateField, Edit, required, SimpleForm, @@ -8,12 +9,7 @@ import { import { CardMedia } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { urlValidate } from '../utils/validations' -import { - Title, - ImageUploadOverlay, - ReadOnlyDateField, - useImageLoadingState, -} from '../common' +import { Title, ImageUploadOverlay, useImageLoadingState } from '../common' import subsonic from '../subsonic' import config from '../config' import { RADIO_PLACEHOLDER_IMAGE } from '../consts' @@ -69,8 +65,8 @@ const RadioEdit = (props) => { fullWidth validate={[urlValidate]} /> - <ReadOnlyDateField source="updatedAt" /> - <ReadOnlyDateField source="createdAt" /> + <DateField variant="body1" source="updatedAt" showTime /> + <DateField variant="body1" source="createdAt" showTime /> </SimpleForm> </Edit> ) diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 0cfb2cd68..945bac519 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -16,12 +16,11 @@ import { } from 'react-admin' import { List, - defaultRowsPerPageOptions, - getStoredPerPage, + useImageUrl, ToggleFieldsMenu, useSelectedFields, } from '../common' -import { Artwork } from '../common/Artwork' +import subsonic from '../subsonic' import { StreamField } from './StreamField' import { setTrack } from '../actions' import { songFromRadio } from './helper' @@ -82,32 +81,15 @@ const RadioListActions = ({ const avatarStyle = { width: 40, height: 40 } -const useCoverStyles = makeStyles({ - cover: { width: 40, height: 40, borderRadius: '4px' }, -}) - const CoverArtField = ({ record }) => { - const classes = useCoverStyles() + const directUrl = record?.uploadedImage + ? subsonic.getCoverArtUrl(record, 40, true) + : null + const { imgUrl } = useImageUrl(directUrl) if (!record) return null - // Radios resolve art only from an uploaded image; otherwise show the generic radio icon. - if (record.uploadedImage) { - return ( - <Artwork - record={record} - size={40} - square - className={classes.cover} - title={record.name} - /> - ) - } + const src = imgUrl || RADIO_PLACEHOLDER_IMAGE return ( - <Avatar - src={RADIO_PLACEHOLDER_IMAGE} - variant="rounded" - style={avatarStyle} - alt={record.name} - /> + <Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} /> ) } CoverArtField.defaultProps = { label: '' } @@ -153,11 +135,7 @@ const RadioList = ({ permissions, ...props }) => { hasCreate={isAdmin} actions={<RadioListActions isAdmin={isAdmin} />} filters={<RadioFilter />} - perPage={getStoredPerPage( - 'radio', - defaultRowsPerPageOptions, - isXsmall ? 25 : 10, - )} + perPage={isXsmall ? 25 : 10} > {isXsmall ? ( <SimpleList diff --git a/ui/src/reducers/settingsReducer.js b/ui/src/reducers/settingsReducer.js index 3f8278ea4..0e598c22d 100644 --- a/ui/src/reducers/settingsReducer.js +++ b/ui/src/reducers/settingsReducer.js @@ -1,7 +1,6 @@ import { SET_NOTIFICATIONS_STATE, SET_OMITTED_FIELDS, - SET_SIDEBAR_PLAYLISTS_FAVOURITES, SET_TOGGLEABLE_FIELDS, } from '../actions' @@ -9,7 +8,6 @@ const initialState = { notifications: false, toggleableFields: {}, omittedFields: {}, - sidebarPlaylistsOnlyFavourites: false, } export const settingsReducer = (previousState = initialState, payload) => { @@ -36,11 +34,6 @@ export const settingsReducer = (previousState = initialState, payload) => { ...data, }, } - case SET_SIDEBAR_PLAYLISTS_FAVOURITES: - return { - ...previousState, - sidebarPlaylistsOnlyFavourites: data, - } default: return previousState } diff --git a/ui/src/reducers/settingsReducer.test.js b/ui/src/reducers/settingsReducer.test.js deleted file mode 100644 index 7ad68b291..000000000 --- a/ui/src/reducers/settingsReducer.test.js +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { settingsReducer } from './settingsReducer' -import { - SET_SIDEBAR_PLAYLISTS_FAVOURITES, - setSidebarPlaylistsOnlyFavourites, -} from '../actions' - -describe('settingsReducer', () => { - it('defaults sidebarPlaylistsOnlyFavourites to false', () => { - const state = settingsReducer(undefined, { type: 'UNKNOWN' }) - expect(state.sidebarPlaylistsOnlyFavourites).toBe(false) - }) - - it('enables the flag via the action creator', () => { - const state = settingsReducer( - undefined, - setSidebarPlaylistsOnlyFavourites(true), - ) - expect(state.sidebarPlaylistsOnlyFavourites).toBe(true) - }) - - it('disables the flag and preserves other settings', () => { - const initial = settingsReducer(undefined, { type: 'UNKNOWN' }) - const on = settingsReducer(initial, { - type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, - data: true, - }) - const off = settingsReducer(on, { - type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, - data: false, - }) - expect(off.sidebarPlaylistsOnlyFavourites).toBe(false) - expect(off.notifications).toEqual(initial.notifications) - expect(off.toggleableFields).toEqual(initial.toggleableFields) - }) -}) diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js index 7cb46e09c..ddb999f3c 100644 --- a/ui/src/setupTests.js +++ b/ui/src/setupTests.js @@ -14,9 +14,6 @@ const localStorageMock = (function () { setItem: function (key, value) { store[key] = value.toString() }, - removeItem: function (key) { - delete store[key] - }, clear: function () { store = {} }, diff --git a/ui/src/share/ShareEdit.jsx b/ui/src/share/ShareEdit.jsx index a222d3369..2cf7f2df7 100644 --- a/ui/src/share/ShareEdit.jsx +++ b/ui/src/share/ShareEdit.jsx @@ -2,16 +2,13 @@ import { DateTimeInput, BooleanInput, Edit, + NumberField, SimpleForm, TextInput, } from 'react-admin' import { sharePlayerUrl } from '../utils' import { Link } from '@material-ui/core' -import { - ReadOnlyDateField, - ReadOnlyNumberField, - ReadOnlyTextField, -} from '../common' +import { DateField } from '../common' import config from '../config' export const ShareEdit = (props) => { @@ -19,26 +16,20 @@ export const ShareEdit = (props) => { const url = sharePlayerUrl(id) return ( <Edit {...props}> - <SimpleForm variant={'outlined'} {...rest}> - <Link - source="URL" - href={url} - target="_blank" - rel="noopener noreferrer" - variant="inherit" - > + <SimpleForm {...rest}> + <Link source="URL" href={url} target="_blank" rel="noopener noreferrer"> {url} </Link> <TextInput source="description" /> {config.enableDownloads && <BooleanInput source="downloadable" />} <DateTimeInput source="expiresAt" /> - <ReadOnlyTextField source="contents" /> - <ReadOnlyTextField source="format" /> - <ReadOnlyTextField source="maxBitRate" /> - <ReadOnlyTextField source="username" /> - <ReadOnlyNumberField source="visitCount" /> - <ReadOnlyDateField source="lastVisitedAt" /> - <ReadOnlyDateField source="createdAt" /> + <TextInput source="contents" disabled /> + <TextInput source="format" disabled /> + <TextInput source="maxBitRate" disabled /> + <TextInput source="username" disabled /> + <NumberField source="visitCount" disabled /> + <DateField source="lastVisitedAt" disabled showTime /> + <DateField source="createdAt" disabled showTime /> </SimpleForm> </Edit> ) diff --git a/ui/src/share/SharePlayer.jsx b/ui/src/share/SharePlayer.jsx index 2384866ea..a3a15e50a 100644 --- a/ui/src/share/SharePlayer.jsx +++ b/ui/src/share/SharePlayer.jsx @@ -1,24 +1,15 @@ import ReactJkMusicPlayer from 'navidrome-music-player' -import { useCallback, useEffect, useRef, useState } from 'react' import config, { shareInfo } from '../config' import { shareCoverUrl, shareDownloadUrl, shareStreamUrl } from '../utils' import { makeStyles } from '@material-ui/core/styles' -// How long the download button stays inert after a click. The browser needs a -// moment to show its own download UI; until then the page looks unresponsive. -export const DOWNLOAD_FEEDBACK_MS = 5000 - const useStyle = makeStyles({ player: { '& .group .next-audio': { pointerEvents: (props) => props.single && 'none', opacity: (props) => props.single && 0.65, }, - '& .group.audio-download': { - pointerEvents: (props) => props.downloading && 'none', - opacity: (props) => props.downloading && 0.65, - }, '@media (min-width: 768px)': { '& .react-jinke-music-player-mobile > div': { width: 768, @@ -32,14 +23,7 @@ const useStyle = makeStyles({ }) const SharePlayer = () => { - const [downloading, setDownloading] = useState(false) - const timer = useRef(null) - const classes = useStyle({ - single: shareInfo?.tracks.length === 1, - downloading, - }) - - useEffect(() => () => clearTimeout(timer.current), []) + const classes = useStyle({ single: shareInfo?.tracks.length === 1 }) const list = shareInfo?.tracks.map((s) => { return { @@ -50,23 +34,11 @@ const SharePlayer = () => { duration: s.duration, } }) - // An anchor, not a navigation: the service worker's NavigationRoute would - // intercept the streamed archive and fail it. - const customDownloader = useCallback(() => { - const link = document.createElement('a') - link.href = shareDownloadUrl(shareInfo?.id) - link.download = '' - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - - setDownloading(true) - clearTimeout(timer.current) - timer.current = setTimeout( - () => setDownloading(false), - DOWNLOAD_FEEDBACK_MS, - ) - }, []) + const onBeforeAudioDownload = () => { + return Promise.resolve({ + src: shareDownloadUrl(shareInfo?.id), + }) + } const options = { audioLists: list, mode: 'full', @@ -87,7 +59,7 @@ const SharePlayer = () => { <ReactJkMusicPlayer {...options} className={classes.player} - customDownloader={customDownloader} + onBeforeAudioDownload={onBeforeAudioDownload} /> ) } diff --git a/ui/src/share/SharePlayer.test.jsx b/ui/src/share/SharePlayer.test.jsx deleted file mode 100644 index 68d1eff59..000000000 --- a/ui/src/share/SharePlayer.test.jsx +++ /dev/null @@ -1,139 +0,0 @@ -import { render, act } from '@testing-library/react' -import SharePlayer, { DOWNLOAD_FEEDBACK_MS } from './SharePlayer' - -let playerProps -let renderCount - -vi.mock('navidrome-music-player', () => ({ - default: (props) => { - playerProps = props - renderCount++ - return <div data-testid="player" /> - }, -})) - -vi.mock('../config', () => ({ - default: { enableDownloads: true }, - shareInfo: { - id: 'share-1', - downloadable: true, - tracks: [{ id: 't1', title: 'One', artist: 'A', duration: 100 }], - }, -})) - -vi.mock('../utils', () => ({ - shareDownloadUrl: (id) => `/share/d/${id}`, - shareStreamUrl: (id) => `/share/s/${id}`, - shareCoverUrl: (id) => `/share/img/${id}`, -})) - -describe('SharePlayer', () => { - let clickSpy - - beforeEach(() => { - vi.useFakeTimers() - playerProps = null - renderCount = 0 - // Downloading for real would navigate the jsdom window. - clickSpy = vi - .spyOn(HTMLAnchorElement.prototype, 'click') - .mockImplementation(() => {}) - }) - - afterEach(() => { - vi.runOnlyPendingTimers() - vi.useRealTimers() - vi.restoreAllMocks() - }) - - it('downloads via an anchor so the service worker does not intercept it', () => { - render(<SharePlayer />) - - let anchor - clickSpy.mockImplementation(function () { - anchor = { href: this.href, download: this.download } - }) - - act(() => { - playerProps.customDownloader() - }) - - expect(clickSpy).toHaveBeenCalledTimes(1) - expect(anchor.href).toContain('/share/d/share-1') - // Empty, so the server's Content-Disposition filename wins. - expect(anchor.download).toBe('') - }) - - it('removes the anchor from the document after clicking', () => { - render(<SharePlayer />) - - act(() => { - playerProps.customDownloader() - }) - - expect(document.querySelectorAll('a[download]')).toHaveLength(0) - }) - - // The inert styling itself is driven by JSS function values, which jsdom does - // not evaluate; it is verified in a browser. What is checked here is the - // state machine feeding it -- that the component re-renders on download and - // again when the window closes. - it('re-renders when the feedback window opens and closes', () => { - render(<SharePlayer />) - - const beforeDownload = renderCount - act(() => { - playerProps.customDownloader() - }) - expect(renderCount).toBeGreaterThan(beforeDownload) - - const beforeExpiry = renderCount - act(() => { - vi.runAllTimers() - }) - expect(renderCount).toBeGreaterThan(beforeExpiry) - }) - - it('restarts the feedback window on a repeat download', () => { - render(<SharePlayer />) - - act(() => { - playerProps.customDownloader() - }) - const elapsedBeforeRepeat = Math.floor(DOWNLOAD_FEEDBACK_MS / 2) - act(() => { - vi.advanceTimersByTime(elapsedBeforeRepeat) - }) - act(() => { - playerProps.customDownloader() - }) - - // Past the first timer's deadline, which it would have fired at had the - // repeat download not replaced it. - const beforeOriginalDeadline = renderCount - act(() => { - vi.advanceTimersByTime(DOWNLOAD_FEEDBACK_MS - elapsedBeforeRepeat + 1) - }) - expect(renderCount).toBe(beforeOriginalDeadline) - - act(() => { - vi.runAllTimers() - }) - expect(renderCount).toBeGreaterThan(beforeOriginalDeadline) - }) - - it('does not update state after unmount', () => { - const { unmount } = render(<SharePlayer />) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - act(() => { - playerProps.customDownloader() - }) - unmount() - act(() => { - vi.runAllTimers() - }) - - expect(errorSpy).not.toHaveBeenCalled() - }) -}) diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index 6b7bfaf96..d928af549 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -26,8 +26,6 @@ import { useResourceRefresh, ArtistLinkField, PathField, - defaultRowsPerPageOptions, - getStoredPerPage, } from '../common' import { useDispatch } from 'react-redux' import { makeStyles } from '@material-ui/core/styles' @@ -145,11 +143,9 @@ const SongList = (props) => { return { album: isDesktop && <AlbumLinkField source="album" sortByOrder={'ASC'} />, artist: <ArtistLinkField source="artist" />, - composer: <ArtistLinkField source="composer" sortable={false} />, + composer: <ArtistLinkField source="composer" />, albumArtist: <ArtistLinkField source="albumArtist" />, - trackNumber: isDesktop && ( - <NumberField source="trackNumber" sortable={false} /> - ), + trackNumber: isDesktop && <NumberField source="trackNumber" />, playCount: isDesktop && ( <NumberField source="playCount" sortByOrder={'DESC'} /> ), @@ -217,11 +213,7 @@ const SongList = (props) => { bulkActionButtons={<SongBulkActions />} actions={<SongListActions />} filters={<SongFilter />} - perPage={getStoredPerPage( - 'song', - defaultRowsPerPageOptions, - isXsmall ? 50 : 15, - )} + perPage={isXsmall ? 50 : 15} > {isXsmall ? ( <SongSimpleList /> diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 9f0ad28d7..7d93972e0 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -81,27 +81,25 @@ const getAvatarUrl = (username, size) => ) const getCoverArtUrl = (record, size, square) => { - const suffix = record.imageHash ? '_' + record.imageHash : '' const options = { - // A hash-suffixed url is already pixel-versioned; the buster would defeat immutable caching. - ...(!record.imageHash && record.updatedAt && { _: record.updatedAt }), + ...(record.updatedAt && { _: record.updatedAt }), ...(size && { size }), ...(square && { square }), } // TODO Move this logic to server if (record.album) { - return baseUrl(url('getCoverArt', 'mf-' + record.id + suffix, options)) + return baseUrl(url('getCoverArt', 'mf-' + record.id, options)) } else if (record.albumArtist) { - return baseUrl(url('getCoverArt', 'al-' + record.id + suffix, options)) + return baseUrl(url('getCoverArt', 'al-' + record.id, options)) } else if (record.sync !== undefined) { // This is a playlist - return baseUrl(url('getCoverArt', 'pl-' + record.id + suffix, options)) + return baseUrl(url('getCoverArt', 'pl-' + record.id, options)) } else if (record.streamUrl !== undefined) { // This is a radio station - return baseUrl(url('getCoverArt', 'ra-' + record.id + suffix, options)) + return baseUrl(url('getCoverArt', 'ra-' + record.id, options)) } else { - return baseUrl(url('getCoverArt', 'ar-' + record.id + suffix, options)) + return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) } } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index 32c699a41..ad4764c24 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -120,47 +120,6 @@ describe('getCoverArtUrl', () => { expect(url).toContain('ar-test-123') expect(url).not.toContain('_=') }) - - it('appends the content hash to the artwork id', () => { - const url = subsonic.getCoverArtUrl({ - id: 'album-123', - albumArtist: 'AA', - imageHash: '0123456789abcdef', - }) - expect(url).toContain('al-album-123_0123456789abcdef') - }) - - it('drops the updatedAt buster once the url is hash-versioned', () => { - const url = subsonic.getCoverArtUrl({ - id: 'album-123', - albumArtist: 'AA', - updatedAt: '2023-01-01T00:00:00Z', - imageHash: '0123456789abcdef', - }) - expect(url).toContain('al-album-123_0123456789abcdef') - expect(url).not.toContain('_=') - }) - - it('keeps the updatedAt buster while artwork is unresolved', () => { - const url = subsonic.getCoverArtUrl({ - id: 'album-123', - albumArtist: 'AA', - updatedAt: '2023-01-01T00:00:00Z', - }) - expect(url).toContain('al-album-123') - expect(url).toContain('_=') - }) - - it('still builds a url for known-absent artwork so the server placeholder renders', () => { - // Returning '' here made <img src=undefined> render as a broken icon; the server serves a - // proper placeholder for absent art, so the client must still request it. - const url = subsonic.getCoverArtUrl({ - id: 'album-123', - albumArtist: 'AA', - imageAbsent: true, - }) - expect(url).toContain('al-album-123') - }) }) describe('getDiscCoverArtUrl', () => { diff --git a/ui/src/sw.js b/ui/src/sw.js index edc272850..f4a5664b8 100644 --- a/ui/src/sw.js +++ b/ui/src/sw.js @@ -1,7 +1,5 @@ /* eslint-disable */ -import { createNavigationHandler } from './swNavigation' - // documentation: https://developers.google.com/web/tools/workbox/modules/workbox-sw importScripts('3rdparty/workbox/workbox-sw.js') @@ -38,13 +36,17 @@ self.addEventListener('install', async (event) => { }) const networkOnly = new workbox.strategies.NetworkOnly() -const fallbackToCachedHtml = () => - caches.match(FALLBACK_HTML_URL, { cacheName: CACHE_NAME }) - -const navigationHandler = createNavigationHandler( - networkOnly, - fallbackToCachedHtml, -) +const navigationHandler = async (params) => { + try { + // Attempt a network request. + return await networkOnly.handle(params) + } catch (error) { + // If it fails, return the cached HTML. + return caches.match(FALLBACK_HTML_URL, { + cacheName: CACHE_NAME, + }) + } +} // self.__WB_MANIFEST is default injection point workbox.precaching.precacheAndRoute(self.__WB_MANIFEST) diff --git a/ui/src/swNavigation.js b/ui/src/swNavigation.js deleted file mode 100644 index 28d01eaef..000000000 --- a/ui/src/swNavigation.js +++ /dev/null @@ -1,14 +0,0 @@ -// Lives outside sw.js so it can be unit tested: sw.js only loads inside a -// service worker, where workbox arrives via importScripts. -export const createNavigationHandler = - (networkOnly, offlineFallback) => async (params) => { - try { - // Attempt a network request. - const response = await networkOnly.handle(params) - // A 5xx reaches us as a normal response, but carries no usable app - return response.status >= 500 ? offlineFallback() : response - } catch (error) { - // If it fails, return the cached HTML. - return offlineFallback() - } - } diff --git a/ui/src/swNavigation.test.js b/ui/src/swNavigation.test.js deleted file mode 100644 index 8ab749757..000000000 --- a/ui/src/swNavigation.test.js +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { createNavigationHandler } from './swNavigation' - -const OFFLINE = { offlinePage: true } - -const setup = (networkResult) => { - const networkOnly = { - handle: vi.fn(() => - networkResult instanceof Error - ? Promise.reject(networkResult) - : Promise.resolve(networkResult), - ), - } - const offlineFallback = vi.fn(() => Promise.resolve(OFFLINE)) - return { - networkOnly, - offlineFallback, - handler: createNavigationHandler(networkOnly, offlineFallback), - } -} - -describe('createNavigationHandler', () => { - it('serves the response from the network', async () => { - const response = { status: 200 } - const { handler, offlineFallback } = setup(response) - - await expect(handler({})).resolves.toBe(response) - expect(offlineFallback).not.toHaveBeenCalled() - }) - - it('falls back to the offline page when the network fails', async () => { - const { handler } = setup(new Error('Failed to fetch')) - - await expect(handler({})).resolves.toBe(OFFLINE) - }) - - it.each([500, 502, 503])( - 'falls back to the offline page on %i', - async (status) => { - const { handler } = setup({ status }) - - await expect(handler({})).resolves.toBe(OFFLINE) - }, - ) - - it.each([304, 401, 404])('passes %i through untouched', async (status) => { - const response = { status } - const { handler, offlineFallback } = setup(response) - - await expect(handler({})).resolves.toBe(response) - expect(offlineFallback).not.toHaveBeenCalled() - }) - - it('passes the navigation params to the network strategy', async () => { - const params = { request: { url: '/app/' } } - const { handler, networkOnly } = setup({ status: 200 }) - - await handler(params) - - expect(networkOnly.handle).toHaveBeenCalledWith(params) - }) -}) diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js index eef7f90d6..74f7d3fd4 100644 --- a/ui/src/themes/amusic.js +++ b/ui/src/themes/amusic.js @@ -79,16 +79,6 @@ export default { color: '#eee', backgroundColor: '#ff4e6b', }, - containedPrimary: { - color: '#fff', - backgroundColor: '#D60017', - '&:hover': { - backgroundColor: '#a30011', - '@media (hover: none)': { - backgroundColor: '#D60017', - }, - }, - }, textSizeSmall: { fontSize: '0.8rem', paddingRight: '0.5rem', @@ -202,16 +192,6 @@ export default { paddingBottom: '1rem', }, }, - NDNotification: { - undo: { - color: '#fff', - }, - }, - RaConfirm: { - confirmPrimary: { - color: '#fff', - }, - }, RaDeleteWithConfirmButton: { deleteButton: { color: '#fff !important', diff --git a/ui/src/themes/catppuccinFrappe.css.js b/ui/src/themes/catppuccinFrappe.css.js deleted file mode 100644 index f927d3ba1..000000000 --- a/ui/src/themes/catppuccinFrappe.css.js +++ /dev/null @@ -1,158 +0,0 @@ -const stylesheet = ` - .react-jinke-music-player-main svg:active, - .react-jinke-music-player-main svg:hover { - color: #949cbb; /* overlay2 */ - } - - .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: #a5adce; /* subtext0 */ - } - - .react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #a5adce; /* subtext0 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #a5adce; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing svg { - color: #a5adce; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing .player-singer { - color: #a5adce !important; - } - - .react-jinke-music-player-main .loading svg { - color: #a5adce !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: #292c3c; /* mantle */ - color: #c6d0f5; /* text */ - box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); - } - - .audio-lists-panel { - background-color: #292c3c; /* mantle */ - 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:rgba(255, 255, 255, 0.08); - } - - .audio-lists-panel-header { - border-bottom:1px solid #414559; /* surface0 */ - } - - .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: #a5adce; /* subtext0 */ - -webkit-text-stroke: 0.5px #303446; /* base */ - font-weight: bolder; - } - - .react-jinke-music-player-main .lyric-btn-active, - .react-jinke-music-player-main .lyric-btn-active svg { - color: #a5adce !important; - } - - .audio-lists-panel-header { - border-bottom:1px solid rgba(0, 0, 0, 0.25); - box-shadow:none; - } - - .audio-lists-panel-content .audio-item.playing, - .audio-lists-panel-content .audio-item.playing svg { - color: #a5adce; /* subtext0 */ - } - - .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: #a5adce; /* subtext0 */ - } - - .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: #51576d; /* surface1 */ - } - -/* 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: #a5adce; /* subtext0 */ - } - - .react-jinke-music-player .music-player-controller .music-player-controller-setting { - color: rgba(122,160,236,.3); - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #a5adce; /* subtext0 */ - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle { - border: none; - } -` - -export default stylesheet diff --git a/ui/src/themes/catppuccinFrappe.js b/ui/src/themes/catppuccinFrappe.js deleted file mode 100644 index 87ff6258f..000000000 --- a/ui/src/themes/catppuccinFrappe.js +++ /dev/null @@ -1,106 +0,0 @@ -import stylesheet from './catppuccinFrappe.css.js' - -export default { - themeName: 'Catppuccin Frappé', - palette: { - primary: { - main: '#ca9ee6', // Mauve - }, - secondary: { - main: '#292c3c', //Mantle - contrastText: '#c6d0f5', // Text - }, - type: 'dark', - background: { - default: '#303446', // Base - }, - }, - overrides: { - MuiPaper: { - root: { - color: '#c6d0f5', // Text - backgroundColor: '#292c3c', // Mantle - MuiSnackbarContent: { - root: { - color: '#c6d0f5', // Text - backgroundColor: '#e78284', // Red - }, - message: { - color: '#c6d0f5', // Text - backgroundColor: '#e78284', // Red - }, - }, - }, - }, - MuiButton: { - textPrimary: { - color: '#8caaee', // Blue - }, - textSecondary: { - color: '#c6d0f5', // Text - }, - }, - MuiChip: { - clickable: { - background: '#292c3c', //Mantle - }, - }, - MuiFormGroup: { - root: { - color: '#c6d0f5', // Text - }, - }, - MuiFormHelperText: { - root: { - Mui: { - error: { - color: '#e78284', // Red - }, - }, - }, - }, - MuiTableHead: { - root: { - color: '#c6d0f5', // Text - background: '#292c3c', //Mantle - }, - }, - MuiTableCell: { - root: { - color: '#c6d0f5', // Text - background: '#292c3c !important', //Mantle - }, - head: { - color: '#c6d0f5', // Text - background: '#292c3c !important', //Mantle - }, - }, - NDLogin: { - systemNameLink: { - color: '#ca9ee6', // Mauve - }, - icon: {}, - welcome: { - color: '#c6d0f5', // Text - }, - card: { - minWidth: 300, - background: '#303446', // Base - }, - avatar: {}, - button: { - boxShadow: '3px 3px 5px #303446', // Base - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/catppuccinLatte.css.js b/ui/src/themes/catppuccinLatte.css.js deleted file mode 100644 index 006601cd9..000000000 --- a/ui/src/themes/catppuccinLatte.css.js +++ /dev/null @@ -1,203 +0,0 @@ -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; /* subtext0 */ - stroke: #6c6f85; /* subtext0 */ - } - - .react-jinke-music-player-main svg:active, - .react-jinke-music-player-main svg:hover { - color: #7c7f93; /* overlay2 */ - } - - .react-jinke-music-player-main.light-theme svg:active, - .react-jinke-music-player-main.light-theme svg:hover { - color: #7c7f93; /* overlay2 */ - } - - .react-jinke-music-player-mobile-play-model-tip, - .react-jinke-music-player-main.light-theme .play-mode-title { - background-color: #6c6f85; /* subtext0 */ - color: #eff1f5; /* base */ - } - - .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; /* subtext0 */ - } - - .react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #6c6f85; /* subtext0 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #6c6f85; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing svg { - color: #6c6f85; /* subtext0 */ - } - - .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; /* subtext0 */ - } - - .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; /* mantle */ - color: #4c4f69; /* text */ - box-shadow: 0 0 8px rgba(76, 79, 105, 0.15); - } - - .react-jinke-music-player-main.light-theme .music-player-panel { - color: #4c4f69; /* text */ - } - - .audio-lists-panel { - background-color: #e6e9ef; /* mantle */ - 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; /* surface0 */ - 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; /* mantle */ - color: #4c4f69; /* text */ - } - - .audio-lists-panel-content .audio-item { - line-height: 32px; - color: #4c4f69; /* text */ - } - - .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; /* base */ - 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; /* subtext0 */ - } - - .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; /* subtext0 */ - } - - .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; /* mantle */ - border-color: #e6e9ef; /* mantle */ - box-shadow: - rgba(76, 79, 105, 0.12) 0px 4px 6px, - rgba(76, 79, 105, 0.08) 0px 5px 7px; - color: #6c6f85; /* subtext0 */ - } - - .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; /* base */ - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle, - .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #6c6f85; /* subtext0 */ - } - - .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 deleted file mode 100644 index 3624cd853..000000000 --- a/ui/src/themes/catppuccinLatte.js +++ /dev/null @@ -1,104 +0,0 @@ -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/catppuccinMacchiato.css.js b/ui/src/themes/catppuccinMacchiato.css.js index e415a990b..d303a0364 100644 --- a/ui/src/themes/catppuccinMacchiato.css.js +++ b/ui/src/themes/catppuccinMacchiato.css.js @@ -1,158 +1,154 @@ const stylesheet = ` - .react-jinke-music-player-main svg:active, - .react-jinke-music-player-main svg:hover { - color: #939ab7; /* overlay2 */ - } - .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: #a5adcb; /* subtext0 */ - } +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #00a4dc +} - .react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #a5adcb; /* subtext0 */ - } +.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: #00a4dc +} - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #a5adcb; /* subtext0 */ - } +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #00a4dc; +} - .react-jinke-music-player-main .audio-item.playing svg { - color: #a5adcb; /* subtext0 */ - } +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #00a4dc +} - .react-jinke-music-player-main .audio-item.playing .player-singer { - color: #a5adcb !important; - } +.react-jinke-music-player-main .audio-item.playing svg { + color: #00a4dc +} - .react-jinke-music-player-main .loading svg { - color: #a5adcb !important; - } +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #00a4dc !important +} + +.react-jinke-music-player-main .loading svg { + color: #00a4dc !important +} - .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-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 { +.rc-slider-rail, .rc-slider-track { height: 6px; - } +} - .rc-slider { +.rc-slider { padding: 3px 0; - } +} - .sound-operation > div:nth-child(4) { +.sound-operation > div:nth-child(4) { transform: translateX(-50%) translateY(5%) !important; - } +} - .sound-operation { +.sound-operation { padding: 4px 0; - } +} - .react-jinke-music-player-main .music-player-panel { - background-color: #1e2030; /* mantle */ - color: #cad3f5; /* text */ +.react-jinke-music-player-main .music-player-panel { + background-color: #1e2030; + color: #24273a box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); - } +} - .audio-lists-panel { - background-color: #1e2030; /* mantle */ +.audio-lists-panel { + background-color: #1e2030; 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 { +.audio-lists-panel-content .audio-item.playing { background-color: rgba(0, 0, 0, 0); - } +} - .audio-lists-panel-content .audio-item:nth-child(2n+1) { +.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 { +.audio-lists-panel-content .audio-item:active, +.audio-lists-panel-content .audio-item:hover { background-color:rgba(255, 255, 255, 0.08); - } +} - .audio-lists-panel-header { - border-bottom:1px solid #363a4f; /* surface0 */ - } +.audio-lists-panel-header { + border-bottom:1px solid #242936; +} - .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { +.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 { +.audio-lists-panel-content .audio-item { line-height: 32px; - } +} - .react-jinke-music-player-main .music-player-panel .panel-content .img-content { +.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: #a5adcb; /* subtext0 */ - -webkit-text-stroke: 0.5px #1e1e2e; /* base */ +.react-jinke-music-player-main .music-player-lyric { + color: #acb3d2; + -webkit-text-stroke: 0.5px #2E3440; font-weight: bolder; - } +} - .react-jinke-music-player-main .lyric-btn-active, - .react-jinke-music-player-main .lyric-btn-active svg { - color: #a5adcb !important; - } +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #acb3d2 !important; +} - .audio-lists-panel-header { +.audio-lists-panel-header { border-bottom:1px solid rgba(0, 0, 0, 0.25); box-shadow:none; - } +} - .audio-lists-panel-content .audio-item.playing, - .audio-lists-panel-content .audio-item.playing svg { - color: #a5adcb; /* subtext0 */ - } +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #00a4dc +} - .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: #a5adcb; /* subtext0 */ - } +.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: #00a4dc +} - .audio-lists-panel-content .audio-item .player-icons { +.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: #494d64; /* surface1 */ - } +.audio-lists-panel-content .audio-item:active, +.audio-lists-panel-content .audio-item:hover { + background-color: #363a4f; +} /* Mobile */ - .react-jinke-music-player-mobile-cover { +.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 { +.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: #a5adcb; /* subtext0 */ - } + color: #00a4dc; +} - .react-jinke-music-player .music-player-controller .music-player-controller-setting { +.react-jinke-music-player .music-player-controller .music-player-controller-setting { color: rgba(122,160,236,.3); - } +} - .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #a5adcb; /* subtext0 */ - } +.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #00a4dc; +} - .react-jinke-music-player-mobile-progress .rc-slider-handle { +.react-jinke-music-player-mobile-progress .rc-slider-handle { border: none; - } +} ` export default stylesheet diff --git a/ui/src/themes/catppuccinMacchiato.js b/ui/src/themes/catppuccinMacchiato.js index b41a7b2ea..63c93ff66 100644 --- a/ui/src/themes/catppuccinMacchiato.js +++ b/ui/src/themes/catppuccinMacchiato.js @@ -4,92 +4,92 @@ export default { themeName: 'Catppuccin Macchiato', palette: { primary: { - main: '#c6a0f6', // Mauve + main: '#c6a0f6', }, secondary: { - main: '#1e2030', // Mantle - contrastText: '#cad3f5', // Text + main: '#1e2030', + contrastText: '#cad3f5', }, type: 'dark', background: { - default: '#24273a', // Base + default: '#24273a', }, }, overrides: { MuiPaper: { root: { - color: '#cad3f5', // Text - backgroundColor: '#1e2030', // Mantle + color: '#cad3f5', + backgroundColor: '#1e2030', MuiSnackbarContent: { root: { - color: '#cad3f5', // Text - backgroundColor: '#ed8796', // Red + color: '#cad3f5', + backgroundColor: '#ed8796', }, message: { - color: '#cad3f5', // Text - backgroundColor: '#ed8796', // Red + color: '#cad3f5', + backgroundColor: '#ed8796', }, }, }, }, MuiButton: { textPrimary: { - color: '#8aadf4', // Blue + color: '#8aadf4', }, textSecondary: { - color: '#cad3f5', // Text + color: '#cad3f5', }, }, MuiChip: { clickable: { - background: '#1e2030', // Mantle + background: '#1e2030', }, }, MuiFormGroup: { root: { - color: '#cad3f5', // Text + color: '#cad3f5', }, }, MuiFormHelperText: { root: { Mui: { error: { - color: '#ed8796', // Red + color: '#ed8796', }, }, }, }, MuiTableHead: { root: { - color: '#cad3f5', // Text - background: '#1e2030', // Mantle + color: '#cad3f5', + background: '#1e2030', }, }, MuiTableCell: { root: { - color: '#cad3f5', // Text - background: '#1e2030 !important', // Mantle + color: '#cad3f5', + background: '#1e2030 !important', }, head: { - color: '#cad3f5', // Text - background: '#1e2030 !important', // Mantle + color: '#cad3f5', + background: '#1e2030 !important', }, }, NDLogin: { systemNameLink: { - color: '#c6a0f6', // Mauve + color: '#c6a0f6', }, icon: {}, welcome: { - color: '#cad3f5', // Text + color: '#cad3f5', }, card: { minWidth: 300, - background: '#24273a', // Base + background: '#24273a', }, avatar: {}, button: { - boxShadow: '3px 3px 5px #24273a', // Base + boxShadow: '3px 3px 5px #24273a', }, }, NDMobileArtistDetails: { diff --git a/ui/src/themes/catppuccinMocha.css.js b/ui/src/themes/catppuccinMocha.css.js deleted file mode 100644 index 7a238468a..000000000 --- a/ui/src/themes/catppuccinMocha.css.js +++ /dev/null @@ -1,158 +0,0 @@ -const stylesheet = ` - .react-jinke-music-player-main svg:active, - .react-jinke-music-player-main svg:hover { - color: #9399b2; /* overlay2 */ - } - - .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: #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing svg { - color: #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing .player-singer { - color: #a6adc8 !important; - } - - .react-jinke-music-player-main .loading svg { - color: #a6adc8 !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: #181825; /* mantle */ - color: #cdd6f4; /* text */ - box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); - } - - .audio-lists-panel { - background-color: #181825; /* mantle */ - 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:rgba(255, 255, 255, 0.08); - } - - .audio-lists-panel-header { - border-bottom:1px solid #313244; /* surface0 */ - } - - .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: #a6adc8; /* subtext0 */ - -webkit-text-stroke: 0.5px #1e1e2e; /* base */ - font-weight: bolder; - } - - .react-jinke-music-player-main .lyric-btn-active, - .react-jinke-music-player-main .lyric-btn-active svg { - color: #a6adc8 !important; - } - - .audio-lists-panel-header { - border-bottom:1px solid rgba(0, 0, 0, 0.25); - box-shadow:none; - } - - .audio-lists-panel-content .audio-item.playing, - .audio-lists-panel-content .audio-item.playing svg { - color: #a6adc8; /* subtext0 */ - } - - .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: #a6adc8; /* subtext0 */ - } - - .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: #45475a; /* surface1 */ - } - -/* 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: #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player .music-player-controller .music-player-controller-setting { - color: rgba(122,160,236,.3); - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle { - border: none; - } -` - -export default stylesheet diff --git a/ui/src/themes/catppuccinMocha.js b/ui/src/themes/catppuccinMocha.js deleted file mode 100644 index bdfb33520..000000000 --- a/ui/src/themes/catppuccinMocha.js +++ /dev/null @@ -1,106 +0,0 @@ -import stylesheet from './catppuccinMocha.css.js' - -export default { - themeName: 'Catppuccin Mocha', - palette: { - primary: { - main: '#cba6f7', // Mauve - }, - secondary: { - main: '#181825', // Mantle - contrastText: '#cdd6f4', // Text - }, - type: 'dark', - background: { - default: '#1e1e2e', // Base - }, - }, - overrides: { - MuiPaper: { - root: { - color: '#cdd6f4', // Text - backgroundColor: '#181825', // Mantle - MuiSnackbarContent: { - root: { - color: '#cdd6f4', // Text - backgroundColor: '#f38ba8', // Red - }, - message: { - color: '#cdd6f4', // Text - backgroundColor: '#f38ba8', // Red - }, - }, - }, - }, - MuiButton: { - textPrimary: { - color: '#89b4fa', // Blue - }, - textSecondary: { - color: '#cdd6f4', // Text - }, - }, - MuiChip: { - clickable: { - background: '#181825', // Mantle - }, - }, - MuiFormGroup: { - root: { - color: '#cdd6f4', // Text - }, - }, - MuiFormHelperText: { - root: { - Mui: { - error: { - color: '#f38ba8', // Red - }, - }, - }, - }, - MuiTableHead: { - root: { - color: '#cdd6f4', // Text - background: '#181825', // Mantle - }, - }, - MuiTableCell: { - root: { - color: '#cdd6f4', // Text - background: '#181825 !important', // Mantle - }, - head: { - color: '#cdd6f4', // Text - background: '#181825 !important', // Mantle - }, - }, - NDLogin: { - systemNameLink: { - color: '#cba6f7', // Mauve - }, - icon: {}, - welcome: { - color: '#cdd6f4', // Text - }, - card: { - minWidth: 300, - background: '#1e1e2e', // Base - }, - avatar: {}, - button: { - boxShadow: '3px 3px 5px #1e1e2e', // Base - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/dracula.js b/ui/src/themes/dracula.js index 45559c3af..2e4ae38e5 100644 --- a/ui/src/themes/dracula.js +++ b/ui/src/themes/dracula.js @@ -185,6 +185,16 @@ export default { color: `${foreground} !important`, }, }, + MuiSwitch: { + colorSecondary: { + '&$checked': { + color: green, + }, + '&$checked + $track': { + backgroundColor: green, + }, + }, + }, NDAlbumGridView: { albumName: { marginTop: '0.5rem', diff --git a/ui/src/themes/gruvboxDark.css.js b/ui/src/themes/gruvboxDark.css.js index f482451b2..dc1f64041 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: #ebdbb2 + background-color: #458588 } .react-jinke-music-player-main ::-webkit-scrollbar-thumb { @@ -50,13 +50,6 @@ 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 3e2955dcd..20f5c732f 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -14,34 +14,22 @@ export default { background: { default: '#282828', }, - text: { - primary: '#ebdbb2', - secondary: '#a89984', - }, }, overrides: { MuiPaper: { root: { color: '#ebdbb2', backgroundColor: '#3c3836', - }, - }, - MuiSnackbarContent: { - root: { - color: '#3c3836', - backgroundColor: '#a89984', - }, - message: { - color: '#3c3836', - backgroundColor: '#a89984', - }, - }, - MuiTypography: { - root: { - color: '#ebdbb2', - }, - colorTextSecondary: { - color: '#a89984', + MuiSnackbarContent: { + root: { + color: '#ebdbb2', + backgroundColor: '#cc241d', + }, + message: { + color: '#ebdbb2', + backgroundColor: '#cc241d', + }, + }, }, }, MuiButton: { @@ -57,19 +45,6 @@ export default { color: '#ebdbb2', }, }, - MuiListItemIcon: { - root: { - color: '#ebdbb2', - }, - }, - MuiListItemText: { - primary: { - color: '#ebdbb2', - }, - secondary: { - color: '#a89984', - }, - }, MuiChip: { clickable: { background: '#49483e', @@ -82,10 +57,11 @@ export default { }, MuiFormHelperText: { root: { - color: '#ebdbb2', - }, - error: { - color: '#cc241d', + Mui: { + error: { + color: '#cc241d', + }, + }, }, }, MuiTableHead: { @@ -121,23 +97,22 @@ export default { boxShadow: '3px 3px 5px #3c3836', }, }, + MuiSwitch: { + colorSecondary: { + '&$checked': { + color: '#458588', + }, + '&$checked + $track': { + backgroundColor: '#458588', + }, + }, + }, NDMobileArtistDetails: { bgContainer: { background: '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 391a97f2c..f65948438 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -8,23 +8,13 @@ import MonokaiTheme from './monokai' import ElectricPurpleTheme from './electricPurple' import NordTheme from './nord' import GruvboxDarkTheme from './gruvboxDark' -import CatppuccinFrappeTheme from './catppuccinFrappe' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' -import CatppuccinMochaTheme from './catppuccinMocha' -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 @@ -33,10 +23,7 @@ export default { // New themes should be added here, in alphabetic order AmusicTheme, - CatppuccinFrappeTheme, - CatppuccinLatteTheme, CatppuccinMacchiatoTheme, - CatppuccinMochaTheme, DraculaTheme, ElectricPurpleTheme, ExtraDarkTheme, @@ -44,17 +31,10 @@ 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 deleted file mode 100644 index 757cfc03c..000000000 --- a/ui/src/themes/moonbaseAlpha.css.js +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 51d8a2696..000000000 --- a/ui/src/themes/moonbaseAlpha.js +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 580b054cc..000000000 --- a/ui/src/themes/moonbaseBravo.css.js +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 87585df29..000000000 --- a/ui/src/themes/moonbaseBravo.js +++ /dev/null @@ -1,90 +0,0 @@ -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 9194ad7a0..65ded5fc5 100644 --- a/ui/src/themes/nautiline.js +++ b/ui/src/themes/nautiline.js @@ -598,9 +598,11 @@ const NautilineTheme = { }, }, NDAlbumGridView: { - link: { + albumContainer: { borderRadius: radii.md, - overflow: 'hidden', + '& img': { + borderRadius: radii.md, + }, }, albumTitle: { fontWeight: 600, @@ -625,6 +627,7 @@ const NautilineTheme = { root: { [`@media (max-width: ${breakpoints.xs}px)`]: { padding: '0.7em', + width: '100%', minWidth: 'unset', }, }, diff --git a/ui/src/themes/nuclear.js b/ui/src/themes/nuclear.js index bf804139e..b34896c97 100644 --- a/ui/src/themes/nuclear.js +++ b/ui/src/themes/nuclear.js @@ -86,13 +86,6 @@ export default { }, }, }, - NDNotification: { - undo: { - '& .MuiButton-label': { - color: 'inherit', - }, - }, - }, MuiChip: { root: { backgroundColor: nukeCol['accent'], diff --git a/ui/src/themes/rosePine.css.js b/ui/src/themes/rosePine.css.js deleted file mode 100644 index a1c21f562..000000000 --- a/ui/src/themes/rosePine.css.js +++ /dev/null @@ -1,148 +0,0 @@ -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 deleted file mode 100644 index 547a1f764..000000000 --- a/ui/src/themes/rosePine.js +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index e3c882815..000000000 --- a/ui/src/themes/rosePineDawn.css.js +++ /dev/null @@ -1,198 +0,0 @@ -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 deleted file mode 100644 index aea903c1a..000000000 --- a/ui/src/themes/rosePineDawn.js +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 0eb7aaf57..000000000 --- a/ui/src/themes/rosePineMoon.css.js +++ /dev/null @@ -1,148 +0,0 @@ -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 deleted file mode 100644 index facf09446..000000000 --- a/ui/src/themes/rosePineMoon.js +++ /dev/null @@ -1,108 +0,0 @@ -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/theme.test.js b/ui/src/themes/theme.test.js index a015ae696..b65c3a5fe 100644 --- a/ui/src/themes/theme.test.js +++ b/ui/src/themes/theme.test.js @@ -12,25 +12,3 @@ describe('NDPlaylistDetails styles', () => { }, ) }) - -describe('NDAlbumGridView styles', () => { - const themeEntries = Object.entries(themes) - - // The hover overlay is a sibling of the image, so it keeps square corners. - it.each(themeEntries)( - '%s should not round the grid cover image on its own', - (themeName, theme) => { - const container = theme.overrides?.NDAlbumGridView?.albumContainer - expect(container?.['& img']?.borderRadius).toBeUndefined() - }, - ) - - it.each(themeEntries)( - '%s should clip the grid cover link when it is rounded', - (themeName, theme) => { - const link = theme.overrides?.NDAlbumGridView?.link - if (!link?.borderRadius) return - expect(link.overflow).toBe('hidden') - }, - ) -}) diff --git a/ui/src/themes/tokyoNight.css.js b/ui/src/themes/tokyoNight.css.js deleted file mode 100644 index 882fcd3eb..000000000 --- a/ui/src/themes/tokyoNight.css.js +++ /dev/null @@ -1,143 +0,0 @@ -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 deleted file mode 100644 index 9f6424b77..000000000 --- a/ui/src/themes/tokyoNight.js +++ /dev/null @@ -1,372 +0,0 @@ -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`, - }, - }, - 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 deleted file mode 100644 index a22c82d03..000000000 --- a/ui/src/themes/tokyoNightLight.css.js +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index a61c0fe87..000000000 --- a/ui/src/themes/tokyoNightLight.js +++ /dev/null @@ -1,372 +0,0 @@ -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`, - }, - }, - 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/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js index fbb5e9bc8..0d986033d 100644 --- a/ui/src/themes/useCurrentTheme.js +++ b/ui/src/themes/useCurrentTheme.js @@ -3,14 +3,10 @@ import useMediaQuery from '@material-ui/core/useMediaQuery' import themes from './index' import { AUTO_THEME_ID } from '../consts' import config from '../config' -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' const useCurrentTheme = () => { - // Runs above the ThemeProvider carrying the prop below, so it needs its own noSsr or the - // auto theme renders dark first and flips. - const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)', { - noSsr: true, - }) + const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)') const theme = useSelector((state) => { if (state.theme === AUTO_THEME_ID) { return prefersLightMode ? themes.LightTheme : themes.DarkTheme @@ -54,21 +50,7 @@ const useCurrentTheme = () => { document.body.style.backgroundColor = bgColor }, [theme]) - // We never server-render, so let media queries resolve on the first render: the default - // defers them to an effect, which makes every mount paint the wrong breakpoint and reflow. - return useMemo( - () => ({ - ...theme, - props: { - ...theme.props, - MuiUseMediaQuery: { noSsr: true }, - MuiPopover: { disableScrollLock: true }, - // MUI defaults to secondary, which many themes use as a surface color - MuiSwitch: { color: 'primary' }, - }, - }), - [theme], - ) + return theme } export default useCurrentTheme diff --git a/ui/src/themes/useCurrentTheme.test.jsx b/ui/src/themes/useCurrentTheme.test.jsx index 6553d9866..65c3be8c6 100644 --- a/ui/src/themes/useCurrentTheme.test.jsx +++ b/ui/src/themes/useCurrentTheme.test.jsx @@ -3,10 +3,6 @@ import { Provider } from 'react-redux' import { createStore } from 'redux' import mediaQuery from 'css-mediaquery' import { renderHook } from '@testing-library/react-hooks' -import { render, screen } from '@testing-library/react' -import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles' -import Switch from '@material-ui/core/Switch' -import themes from './index' import useCurrentTheme from './useCurrentTheme' import { themeReducer } from '../reducers/themeReducer' import { AUTO_THEME_ID } from '../consts' @@ -165,27 +161,4 @@ describe('useCurrentTheme', () => { expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)') }) }) - describe('switch color', () => { - it.each(Object.keys(themes))( - 'renders switches with the primary color in %s', - (theme) => { - const { result } = renderHook(() => useCurrentTheme(), { - wrapper: ({ children }) => ( - <Provider store={createStore(themeReducer, { theme })}> - {children} - </Provider> - ), - }) - render( - <ThemeProvider theme={createMuiTheme(result.current)}> - <Switch checked onChange={() => {}} /> - </ThemeProvider>, - ) - const switchBase = screen - .getByRole('checkbox') - .closest('.MuiSwitch-switchBase') - expect(switchBase.classList).toContain('MuiSwitch-colorPrimary') - }, - ) - }) }) diff --git a/ui/src/transcoding/TranscodingCreate.jsx b/ui/src/transcoding/TranscodingCreate.jsx index 014a94e48..aaf122665 100644 --- a/ui/src/transcoding/TranscodingCreate.jsx +++ b/ui/src/transcoding/TranscodingCreate.jsx @@ -8,7 +8,7 @@ import { useTranslate, } from 'react-admin' import { Title } from '../common' -import { TRANSCODING_BITRATE_CHOICES } from '../consts' +import { BITRATE_CHOICES } from '../consts' const TranscodingTitle = () => { const translate = useTranslate() @@ -28,7 +28,7 @@ const TranscodingCreate = (props) => ( <TextInput source="targetFormat" validate={[required()]} /> <SelectInput source="defaultBitRate" - choices={TRANSCODING_BITRATE_CHOICES} + choices={BITRATE_CHOICES} defaultValue={192} /> <TextInput diff --git a/ui/src/transcoding/TranscodingEdit.jsx b/ui/src/transcoding/TranscodingEdit.jsx index bf500920f..5aba9a4e3 100644 --- a/ui/src/transcoding/TranscodingEdit.jsx +++ b/ui/src/transcoding/TranscodingEdit.jsx @@ -9,7 +9,7 @@ import { } from 'react-admin' import { Title } from '../common' import { TranscodingNote } from './TranscodingNote' -import { TRANSCODING_BITRATE_CHOICES } from '../consts' +import { BITRATE_CHOICES } from '../consts' const TranscodingTitle = ({ record }) => { const translate = useTranslate() @@ -28,10 +28,7 @@ const TranscodingEdit = (props) => { <SimpleForm variant={'outlined'}> <TextInput source="name" validate={[required()]} /> <TextInput source="targetFormat" validate={[required()]} /> - <SelectInput - source="defaultBitRate" - choices={TRANSCODING_BITRATE_CHOICES} - /> + <SelectInput source="defaultBitRate" choices={BITRATE_CHOICES} /> <TextInput source="command" fullWidth validate={[required()]} /> </SimpleForm> </Edit> diff --git a/ui/src/transcoding/TranscodingList.jsx b/ui/src/transcoding/TranscodingList.jsx index d1371c04a..bca8b49df 100644 --- a/ui/src/transcoding/TranscodingList.jsx +++ b/ui/src/transcoding/TranscodingList.jsx @@ -1,8 +1,7 @@ import React from 'react' -import { Datagrid, SelectField, TextField } from 'react-admin' +import { Datagrid, TextField } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import { SimpleList, List } from '../common' -import { TRANSCODING_BITRATE_CHOICES } from '../consts' import config from '../config' const TranscodingList = (props) => { @@ -17,22 +16,13 @@ const TranscodingList = (props) => { <SimpleList primaryText={(r) => r.name} secondaryText={(r) => `format: ${r.targetFormat}`} - tertiaryText={(r) => ( - <SelectField - record={r} - source="defaultBitRate" - choices={TRANSCODING_BITRATE_CHOICES} - /> - )} + tertiaryText={(r) => r.defaultBitRate} /> ) : ( <Datagrid rowClick={config.enableTranscodingConfig ? 'edit' : 'show'}> <TextField source="name" /> <TextField source="targetFormat" /> - <SelectField - source="defaultBitRate" - choices={TRANSCODING_BITRATE_CHOICES} - /> + <TextField source="defaultBitRate" /> <TextField source="command" /> </Datagrid> )} diff --git a/ui/src/transcoding/TranscodingShow.jsx b/ui/src/transcoding/TranscodingShow.jsx index b7ec2f595..e132afee5 100644 --- a/ui/src/transcoding/TranscodingShow.jsx +++ b/ui/src/transcoding/TranscodingShow.jsx @@ -1,8 +1,7 @@ import React from 'react' -import { SelectField, Show, SimpleShowLayout, TextField } from 'react-admin' +import { Show, SimpleShowLayout, TextField } from 'react-admin' import { Title } from '../common' import { TranscodingNote } from './TranscodingNote' -import { TRANSCODING_BITRATE_CHOICES } from '../consts' const TranscodingTitle = ({ record }) => { return <Title subTitle={`Transcoding ${record ? record.name : ''}`} /> @@ -17,10 +16,7 @@ const TranscodingShow = (props) => { <SimpleShowLayout> <TextField source="name" /> <TextField source="targetFormat" /> - <SelectField - source="defaultBitRate" - choices={TRANSCODING_BITRATE_CHOICES} - /> + <TextField source="defaultBitRate" /> <TextField source="command" /> </SimpleShowLayout> </Show> diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index feadafff1..2283dd8bc 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -3,6 +3,7 @@ import { makeStyles } from '@material-ui/core/styles' import { TextInput, BooleanInput, + DateField, PasswordInput, Edit, required, @@ -20,7 +21,7 @@ import { useRecordContext, } from 'react-admin' import { Typography } from '@material-ui/core' -import { ReadOnlyDateField, Title } from '../common' +import { Title } from '../common' import DeleteUserButton from './DeleteUserButton' import { LibrarySelectionField } from './LibrarySelectionField.jsx' import { validateUserForm } from './userValidation' @@ -95,10 +96,9 @@ 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], @@ -171,21 +171,10 @@ const UserEdit = (props) => { </FormDataConsumer> )} - <TextInput - source="scrobbleFilter" - multiline - minRows={2} - fullWidth - spellCheck={false} - style={{ maxWidth: '40em' }} - inputProps={{ style: { resize: 'vertical' } }} - helperText={translate('resources.user.helperTexts.scrobbleFilter')} - /> - - <ReadOnlyDateField source="lastLoginAt" /> - <ReadOnlyDateField source="lastAccessAt" /> - <ReadOnlyDateField source="updatedAt" /> - <ReadOnlyDateField source="createdAt" /> + <DateField variant="body1" source="lastLoginAt" showTime /> + <DateField variant="body1" source="lastAccessAt" showTime /> + <DateField variant="body1" source="updatedAt" showTime /> + <DateField variant="body1" source="createdAt" showTime /> </SimpleForm> </Edit> ) diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx index 837b25ad6..75a9a1ada 100644 --- a/ui/src/user/UserEdit.test.jsx +++ b/ui/src/user/UserEdit.test.jsx @@ -27,14 +27,6 @@ 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 }) => ( @@ -43,14 +35,16 @@ vi.mock('react-admin', () => ({ {children} </div> ), - SimpleForm: ({ children, save }) => { - hooks.save = save - return <form data-testid="simple-form">{children}</form> - }, + SimpleForm: ({ children }) => ( + <form data-testid="simple-form">{children}</form> + ), TextInput: ({ source }) => <input data-testid={`text-input-${source}`} />, BooleanInput: ({ source }) => ( <input type="checkbox" data-testid={`boolean-input-${source}`} /> ), + DateField: ({ source }) => ( + <div data-testid={`date-field-${source}`}>Date</div> + ), PasswordInput: ({ source }) => ( <input type="password" data-testid={`password-input-${source}`} /> ), @@ -60,10 +54,10 @@ vi.mock('react-admin', () => ({ Typography: ({ children }) => <p>{children}</p>, required: () => () => null, email: () => () => null, - useMutation: () => [hooks.mutate], - useNotify: () => hooks.notify, - useRedirect: () => hooks.redirect, - useRefresh: () => hooks.refresh, + useMutation: () => [vi.fn()], + useNotify: () => vi.fn(), + useRedirect: () => vi.fn(), + useRefresh: () => vi.fn(), usePermissions: () => ({ permissions: 'admin' }), useTranslate: () => (key) => key, })) @@ -79,9 +73,6 @@ vi.mock('./DeleteUserButton', () => ({ vi.mock('../common', () => ({ Title: ({ subTitle }) => <div data-testid="title">{subTitle}</div>, - ReadOnlyDateField: ({ source }) => ( - <div data-testid={`date-field-${source}`}>Date</div> - ), })) // Mock Material-UI @@ -127,12 +118,6 @@ describe('<UserEdit />', () => { expect(screen.getByTestId('date-field-createdAt')).toBeInTheDocument() }) - it('should render the scrobble filter input', () => { - render(<UserEdit id="user1" permissions="admin" />) - - expect(screen.getByTestId('text-input-scrobbleFilter')).toBeInTheDocument() - }) - it('should not render username input for non-admin users', () => { render(<UserEdit id="user1" permissions="user" />) @@ -142,60 +127,4 @@ describe('<UserEdit />', () => { 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(<UserEdit id="user1" permissions="admin" />) - - 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(<UserEdit id="user1" permissions="admin" />) - - 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(<UserEdit id="user1" permissions="admin" />) - - 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(<UserEdit id="user1" permissions="admin" />) - - await hooks.save({ id: 'user1' }) - - expect(hooks.notify).toHaveBeenCalledWith('ra.page.error', 'warning') - expect(hooks.redirect).not.toHaveBeenCalled() - }) - }) }) diff --git a/ui/src/user/UserList.jsx b/ui/src/user/UserList.jsx index 7922ca08d..4faf31785 100644 --- a/ui/src/user/UserList.jsx +++ b/ui/src/user/UserList.jsx @@ -9,8 +9,6 @@ import { } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import { List, DateField } from '../common' -import { useDateLocale } from '../i18n/useDateLocale' -import { formatDateTime } from '../utils/formatters' const UserFilter = (props) => ( <Filter {...props} variant={'outlined'}> @@ -20,7 +18,6 @@ const UserFilter = (props) => ( const UserList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) - const locale = useDateLocale() return ( <List @@ -34,7 +31,7 @@ const UserList = (props) => { <SimpleList primaryText={(record) => record.userName} secondaryText={(record) => - record.lastLoginAt && formatDateTime(record.lastLoginAt, locale) + record.lastLoginAt && new Date(record.lastLoginAt).toLocaleString() } tertiaryText={(record) => (record.isAdmin ? '[admin]️' : '')} /> diff --git a/ui/src/user/userValidation.js b/ui/src/user/userValidation.js index 9eb1b8f05..e90fd2acb 100644 --- a/ui/src/user/userValidation.js +++ b/ui/src/user/userValidation.js @@ -15,15 +15,5 @@ export const validateUserForm = (values, translate) => { } } - if (values.scrobbleFilter && values.scrobbleFilter.trim() !== '') { - try { - JSON.parse(values.scrobbleFilter) - } catch { - errors.scrobbleFilter = translate( - 'resources.user.validation.invalidScrobbleFilter', - ) - } - } - return errors } diff --git a/ui/src/user/userValidation.test.js b/ui/src/user/userValidation.test.js index de52c17df..2ee473910 100644 --- a/ui/src/user/userValidation.test.js +++ b/ui/src/user/userValidation.test.js @@ -67,27 +67,4 @@ describe('User Validation Utilities', () => { ) }) }) - - describe('scrobbleFilter validation', () => { - it('accepts an empty filter', () => { - const errors = validateUserForm({ isAdmin: true }, mockTranslate) - expect(errors.scrobbleFilter).toBeUndefined() - }) - it('accepts valid JSON', () => { - const errors = validateUserForm( - { isAdmin: true, scrobbleFilter: '{"all":[{"lt":{"rating":4}}]}' }, - mockTranslate, - ) - expect(errors.scrobbleFilter).toBeUndefined() - }) - it('rejects malformed JSON', () => { - const errors = validateUserForm( - { isAdmin: true, scrobbleFilter: '{broken' }, - mockTranslate, - ) - expect(errors.scrobbleFilter).toEqual( - 'resources.user.validation.invalidScrobbleFilter', - ) - }) - }) }) diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js index f7c59c195..cfcb84b05 100644 --- a/ui/src/utils/formatters.js +++ b/ui/src/utils/formatters.js @@ -95,9 +95,6 @@ export const formatFullDate = (date, locale) => { return new Date(date).toLocaleDateString(locale, options) } -export const formatDateTime = (value, locale) => - new Date(value).toLocaleString(locale) - export const formatNumber = (value, locale) => { if (value === null || value === undefined) return '0' return value.toLocaleString(locale) diff --git a/ui/src/utils/removeHomeCache.js b/ui/src/utils/removeHomeCache.js new file mode 100644 index 000000000..08ed720e0 --- /dev/null +++ b/ui/src/utils/removeHomeCache.js @@ -0,0 +1,20 @@ +export const removeHomeCache = async () => { + try { + const workboxKey = (await caches.keys()).find((key) => + key.startsWith('workbox-precache'), + ) + if (!workboxKey) return + + const workboxCache = await caches.open(workboxKey) + const indexKey = (await workboxCache.keys()).find((key) => + key.url.includes('app/index.html'), + ) + + if (indexKey) { + await workboxCache.delete(indexKey) + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('error reading cache', e) + } +} diff --git a/ui/src/utils/thumbhash.js b/ui/src/utils/thumbhash.js deleted file mode 100644 index f4ec673d2..000000000 --- a/ui/src/utils/thumbhash.js +++ /dev/null @@ -1,138 +0,0 @@ -// ThumbHash decoder (https://github.com/evanw/thumbhash), kept byte-compatible with the reference: -// core/artwork/thumbhash/testdata/thumbhash.js pins the pixels the specs assert on. - -const toBytes = (hash) => { - if (typeof hash !== 'string' || hash === '') { - throw new Error('thumbhash: empty hash') - } - const binary = atob(hash) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - if (bytes.length < 5) { - throw new Error('thumbhash: hash too short') - } - return bytes -} - -// header unpacks the fixed 5-byte prefix plus the optional alpha byte. -const header = (bytes) => { - const header24 = bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) - const header16 = bytes[3] | (bytes[4] << 8) - const hasAlpha = header24 >> 23 !== 0 - if (hasAlpha && bytes.length < 6) { - throw new Error('thumbhash: hash too short for alpha') - } - const isLandscape = header16 >> 15 !== 0 - const alphaLimit = hasAlpha ? 5 : 7 - return { - lDC: (header24 & 63) / 63, - pDC: ((header24 >> 6) & 63) / 31.5 - 1, - qDC: ((header24 >> 12) & 63) / 31.5 - 1, - lScale: ((header24 >> 18) & 31) / 31, - hasAlpha, - pScale: ((header16 >> 3) & 63) / 63, - qScale: ((header16 >> 9) & 63) / 63, - lx: Math.max(3, isLandscape ? alphaLimit : header16 & 7), - ly: Math.max(3, isLandscape ? header16 & 7 : alphaLimit), - aDC: hasAlpha ? (bytes[5] & 15) / 15 : 1, - aScale: hasAlpha ? (bytes[5] >> 4) / 15 : 0, - } -} - -// naturalSize is the reference decoder's own output size: 32 on the long edge, at the aspect the -// hash approximates. Callers with the true dimensions should decode at those instead. -export const naturalSize = (hash) => { - const { lx, ly } = header(toBytes(hash)) - const ratio = lx / ly - return ratio > 1 - ? { width: 32, height: Math.round(32 / ratio) } - : { width: Math.round(32 * ratio), height: 32 } -} - -const cosTable = (n, size) => { - const table = new Float64Array(n * size) - for (let c = 0; c < n; c++) { - for (let i = 0; i < size; i++) { - table[c * size + i] = Math.cos((Math.PI / size) * (i + 0.5) * c) - } - } - return table -} - -export const decode = (hash, width, height) => { - const bytes = toBytes(hash) - const h = header(bytes) - if (!(width > 0) || !(height > 0)) { - throw new Error('thumbhash: width and height must be positive') - } - - const acStart = h.hasAlpha ? 6 : 5 - let acIndex = 0 - const channel = (nx, ny, scale) => { - const ac = [] - for (let cy = 0; cy < ny; cy++) { - for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) { - const byte = bytes[acStart + (acIndex >> 1)] ?? 0 - ac.push((((byte >> ((acIndex++ & 1) << 2)) & 15) / 7.5 - 1) * scale) - } - } - return ac - } - // The 1.25x chroma boost is the reference's quantisation compensation, not a free parameter. - const lAC = channel(h.lx, h.ly, h.lScale) - const pAC = channel(3, 3, h.pScale * 1.25) - const qAC = channel(3, 3, h.qScale * 1.25) - const aAC = h.hasAlpha ? channel(5, 5, h.aScale) : [] - - const nx = Math.max(h.lx, h.hasAlpha ? 5 : 3) - const ny = Math.max(h.ly, h.hasAlpha ? 5 : 3) - const fx = cosTable(nx, width) - const fy = cosTable(ny, height) - - const pixels = new Uint8ClampedArray(width * height * 4) - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - let l = h.lDC - let p = h.pDC - let q = h.qDC - let a = h.aDC - - for (let cy = 0, j = 0; cy < h.ly; cy++) { - const fy2 = fy[cy * height + y] * 2 - for (let cx = cy ? 0 : 1; cx * h.ly < h.lx * (h.ly - cy); cx++, j++) { - l += lAC[j] * fx[cx * width + x] * fy2 - } - } - for (let cy = 0, j = 0; cy < 3; cy++) { - const fy2 = fy[cy * height + y] * 2 - for (let cx = cy ? 0 : 1; cx < 3 - cy; cx++, j++) { - const f = fx[cx * width + x] * fy2 - p += pAC[j] * f - q += qAC[j] * f - } - } - if (h.hasAlpha) { - for (let cy = 0, j = 0; cy < 5; cy++) { - const fy2 = fy[cy * height + y] * 2 - for (let cx = cy ? 0 : 1; cx < 5 - cy; cx++, j++) { - a += aAC[j] * fx[cx * width + x] * fy2 - } - } - } - - const b = l - (2 / 3) * p - const r = (3 * l - b + q) / 2 - const g = r - q - const idx = 4 * x + y * width * 4 - // Explicit floor: the reference writes into a Uint8Array, which truncates, while the - // Uint8ClampedArray that createImageData needs would round. - pixels[idx] = Math.floor(Math.max(0, 255 * Math.min(1, r))) - pixels[idx + 1] = Math.floor(Math.max(0, 255 * Math.min(1, g))) - pixels[idx + 2] = Math.floor(Math.max(0, 255 * Math.min(1, b))) - pixels[idx + 3] = Math.floor(Math.max(0, 255 * Math.min(1, a))) - } - } - return pixels -} diff --git a/ui/src/utils/thumbhash.test.js b/ui/src/utils/thumbhash.test.js deleted file mode 100644 index 6abb4d7e4..000000000 --- a/ui/src/utils/thumbhash.test.js +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { decode, naturalSize } from './thumbhash' - -// Digests pinned from evanw/thumbhash's reference decoder (vendored at -// core/artwork/thumbhash/testdata/thumbhash.js), so any drift from it fails here. -const fnv = (bytes) => { - let h = 2166136261 >>> 0 - for (const b of bytes) { - h ^= b - h = Math.imul(h, 16777619) >>> 0 - } - return h.toString(16) -} - -const REFERENCE = [ - ['alpha', 'JOiFBQ4nkIexh3p4iA8uB+lYhIeAh3d4dw==', 32, 32, '500141bc'], - ['landscape', '3wcOFJpwh4eBh3d4iIePgAj3hw==', 32, 18, '18465aed'], - ['portrait', '3/cNFBqBB4iId4d3d4iAjwj4hw==', 18, 32, '56500cc1'], - ['solid', 'HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA', 32, 32, '39cc5c5'], - ['square', 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I', 32, 32, '740d8afc'], - ['tiny', 'HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP', 32, 32, 'f2555987'], -] - -describe('thumbhash decode', () => { - it.each(REFERENCE)( - 'reproduces the reference pixels for %s', - (_name, hash, w, h, digest) => { - expect(fnv(decode(hash, w, h))).toEqual(digest) - }, - ) - - it.each(REFERENCE)( - 'reports the reference natural size for %s', - (_name, hash, w, h) => { - expect(naturalSize(hash)).toEqual({ width: w, height: h }) - }, - ) - - it('decodes to any requested grid, not just the natural one', () => { - const [, hash] = REFERENCE[4] - const pixels = decode(hash, 8, 5) - expect(pixels).toHaveLength(8 * 5 * 4) - // Opaque hash: every alpha byte is saturated. - for (let i = 3; i < pixels.length; i += 4) { - expect(pixels[i]).toBe(255) - } - }) - - it('carries alpha through for a hash that has it', () => { - const [, hash, w, h] = REFERENCE[0] - const pixels = decode(hash, w, h) - const alphas = new Set() - for (let i = 3; i < pixels.length; i += 4) { - alphas.add(pixels[i]) - } - expect(alphas.size).toBeGreaterThan(1) - }) - - it.each([ - ['empty', ''], - ['too short to hold a header', 'AAAA'], - ['not base64', '!!!not-a-thumbhash!!!'], - ])('throws on a hash that is %s', (_name, hash) => { - expect(() => decode(hash, 32, 32)).toThrow() - }) -}) diff --git a/ui/vite.config.js b/ui/vite.config.js index 182e825b0..9d9c845f1 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -16,9 +16,6 @@ export default defineConfig({ filename: 'sw.js', injectManifest: { maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB - // index.html is rendered per-user by the server, so a precached copy - // would pin one user's config (and auth payload) across logins - globIgnores: ['index.html'], }, devOptions: { enabled: true, diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go index 9ab07cf18..1fe448f84 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 = conf.NewDir(tmpDir) + conf.Server.CacheFolder = 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 range n { + for g := 0; g < n; g++ { 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 range n { + for g := 0; g < n; g++ { 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 4eed243dd..94d33100b 100644 --- a/utils/cache/cached_http_client.go +++ b/utils/cache/cached_http_client.go @@ -75,7 +75,8 @@ func (c *HTTPClient) serializeReq(req *http.Request) string { } if req.Body != nil { bodyData, _ := io.ReadAll(req.Body) - data.Body = new(base64.StdEncoding.EncodeToString(bodyData)) + bodyStr := base64.StdEncoding.EncodeToString(bodyData) + data.Body = &bodyStr } 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 5f8b0029c..1ec1a3a27 100644 --- a/utils/cache/cached_http_client_test.go +++ b/utils/cache/cached_http_client_test.go @@ -20,8 +20,6 @@ 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 48cd135cb..5edc533f8 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -2,10 +2,8 @@ package cache import ( "context" - "errors" "fmt" "io" - "io/fs" "path/filepath" "sync" "sync/atomic" @@ -87,11 +85,10 @@ func NewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader R go func() { start := time.Now() - cache, sfs, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems) + cache, 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) @@ -112,7 +109,6 @@ type fileCache struct { cacheFolder string maxItems int cache fscache.Cache - fs *spreadFS getReader ReadFunc disabled bool ready atomic.Bool @@ -160,13 +156,6 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { key := arg.Key() r, w, err := fc.cache.Get(key) - if errors.Is(err, fs.ErrNotExist) { - // The entry outlived its data file. Drop it and retry, or every future Get - // for this key fails for the rest of the process's life. - log.Debug(ctx, "Cache entry lost its data file. Re-fetching", "cache", fc.name, "key", key) - _ = fc.invalidate(ctx, key) - r, w, err = fc.cache.Get(key) - } if err != nil { return nil, err } @@ -183,7 +172,7 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { return nil, err } go func() { - if err := fc.copyAndClose(ctx, key, w, reader); err != nil { + if err := copyAndClose(w, reader); err != nil { log.Debug(ctx, "Error storing file in cache", "cache", fc.name, "key", key, err) _ = fc.invalidate(ctx, key) } else { @@ -242,8 +231,7 @@ func getFinalCachedSize(r fscache.ReadAtCloser) int64 { return -1 } -// copyAndClose marks the entry complete before closing w, so EOF implies the entry is settled on disk. -func (fc *fileCache) copyAndClose(ctx context.Context, key string, w io.WriteCloser, r io.Reader) error { +func copyAndClose(w io.WriteCloser, r io.Reader) error { _, err := io.Copy(w, r) if err != nil { err = fmt.Errorf("copying data to cache: %w", err) @@ -253,38 +241,14 @@ func (fc *fileCache) copyAndClose(ctx context.Context, key string, w io.WriteClo err = multierror.Append(err, fmt.Errorf("closing source stream: %w", cErr)) } } - if err == nil { - fc.markComplete(ctx, key) - } else if cw, ok := w.(interface{ CloseWithError(error) error }); ok { - // Cancel instead of close, so readers fail with the cause rather than - // draining a truncated entry to a clean EOF. - if cErr := cw.CloseWithError(err); cErr != nil { - // Join, not Append: err is now shared with readers and must not be mutated. - return errors.Join(err, fmt.Errorf("closing cache writer: %w", cErr)) - } - return err - } else { - log.Warn(ctx, "Cache writer cannot report failures; readers will see a truncated entry as a clean EOF", - "cache", fc.name, "key", key, err) - } + if cErr := w.Close(); cErr != nil { err = multierror.Append(err, fmt.Errorf("closing cache writer: %w", cErr)) } return err } -// 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) { +func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) { size, err := humanize.ParseBytes(cacheSize) if err != nil { log.Error("Invalid cache size. Using default size", "cache", name, "size", cacheSize, @@ -293,27 +257,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, nil + return nil, nil } lru := NewFileHaunter(name, maxItems, size, consts.DefaultCacheCleanUpInterval) h := fscache.NewLRUHaunterStrategy(lru) - cacheFolder = filepath.Join(conf.Server.CacheFolder.MustPath(), cacheFolder) + cacheFolder = filepath.Join(conf.Server.CacheFolder, 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, nil, err + return nil, err } ck, err := fscache.NewCacheWithHaunter(fs, h) if err != nil { log.Error(fmt.Sprintf("Error initializing %s cache", name), err) - return nil, nil, err + return nil, err } ck.SetKeyMapper(fs.KeyMapper) - return ck, fs, nil + return ck, nil } diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index 3189de6b2..72f4463d1 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -7,8 +7,6 @@ import ( "os" "path/filepath" "strings" - "sync/atomic" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -19,7 +17,7 @@ import ( // Call NewFileCache and wait for it to be ready func callNewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) *fileCache { fc := NewFileCache(name, cacheSize, cacheFolder, maxItems, getReader).(*fileCache) - Eventually(func() bool { return fc.ready.Load() }, 10*time.Second).Should(BeTrue()) + Eventually(func() bool { return fc.ready.Load() }).Should(BeTrue()) return fc } @@ -30,14 +28,14 @@ var _ = Describe("File Caches", func() { configtest.SetupConfig() _ = os.RemoveAll(tmpDir) }) - conf.Server.CacheFolder = conf.NewDir(tmpDir) + conf.Server.CacheFolder = 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.String(), "test")) + _, err := os.Stat(filepath.Join(conf.Server.CacheFolder, "test")) Expect(os.IsNotExist(err)).To(BeFalse()) }) @@ -106,74 +104,6 @@ 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() - - // EOF must imply the entry is settled on disk (Windows temp-dir cleanups rely on it). - dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"markme"}).Key()) - _, statErr := os.Stat(dataPath + ".complete") - Expect(statErr).ToNot(HaveOccurred()) - }) - - 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() { @@ -208,211 +138,6 @@ 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("gets a writer that can report failures to readers", func() { - // Guards the fork adoption: if the fscache replace directive is ever lost, - // this fails in CI instead of silently reviving the truncation bug. - fc := callNewFileCache("test", "10MB", "test", 0, nil) - _, w, err := fc.cache.Get("capability") - Expect(err).To(BeNil()) - DeferCleanup(func() { _ = w.Close() }) - - _, ok := w.(interface{ CloseWithError(error) error }) - Expect(ok).To(BeTrue(), "fscache writer lost CloseWithError; check the go.mod replace directive") - }) - - It("fails the reader with the cause instead of a clean EOF", func() { - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - return &partialThenErrReader{data: []byte("PARTIAL"), err: errors.New("transcoder died")}, nil - }) - s, err := fc.Get(context.Background(), &testArg{"inband"}) - Expect(err).To(BeNil()) - DeferCleanup(func() { _ = s.Close() }) - - _, err = io.ReadAll(s) - Expect(err).To(MatchError(ContainSubstring("transcoder died"))) - }) - - It("fails a reader that joined mid-write with the same cause", func() { - pr, pw := io.Pipe() - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - return pr, nil - }) - s1, err := fc.Get(context.Background(), &testArg{"joined"}) - Expect(err).To(BeNil()) - DeferCleanup(func() { _ = s1.Close() }) - - // The blocking pipe write gives a happens-before: the entry is in flight. - _, err = pw.Write([]byte("PARTIAL")) - Expect(err).To(BeNil()) - - s2, err := fc.Get(context.Background(), &testArg{"joined"}) - Expect(err).To(BeNil()) - DeferCleanup(func() { _ = s2.Close() }) - Expect(s2.Cached).To(BeTrue()) - - Expect(pw.CloseWithError(errors.New("transcoder died"))).To(Succeed()) - - _, err = io.ReadAll(s2) - Expect(err).To(MatchError(ContainSubstring("transcoder died"))) - }) - - 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()) - }) - }) - - Context("entry outliving its data file", func() { - It("re-fetches when the data file vanished behind the cache's back", func() { - var calls atomic.Int32 - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - calls.Add(1) - return strings.NewReader("payload"), nil - }) - - s, err := fc.Get(context.Background(), &testArg{"vanish"}) - Expect(err).To(BeNil()) - Expect(io.ReadAll(s)).To(Equal([]byte("payload"))) - Expect(s.Close()).To(Succeed()) - - dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"vanish"}).Key()) - Eventually(func() error { _, e := os.Stat(dataPath); return e }).Should(Succeed()) - Expect(os.Remove(dataPath)).To(Succeed()) - - s2, err := fc.Get(context.Background(), &testArg{"vanish"}) - Expect(err).ToNot(HaveOccurred()) - Expect(io.ReadAll(s2)).To(Equal([]byte("payload"))) - _ = s2.Close() - Expect(calls.Load()).To(BeNumerically("==", 2)) - }) - - It("removes a failed entry promptly, without eating its replacement", func() { - // Cancel closes the failed entry's readers, so its removal no longer defers - // past the point where a new entry re-creates the same file. - var n atomic.Int32 - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - if n.Add(1) == 1 { - return &partialThenErrReader{data: []byte("PARTIAL"), err: errors.New("died")}, nil - } - return strings.NewReader("GOOD"), nil - }) - - key := (&testArg{"deferred"}).Key() - s1, err := fc.Get(context.Background(), &testArg{"deferred"}) - Expect(err).To(BeNil()) - - Eventually(func() bool { return fc.cache.Exists(key) }).Should(BeFalse()) - - s2, err := fc.Get(context.Background(), &testArg{"deferred"}) - Expect(err).To(BeNil()) - Expect(io.ReadAll(s2)).To(Equal([]byte("GOOD"))) - Expect(s2.Close()).To(Succeed()) - - Expect(s1.Close()).To(Succeed()) - - dataPath := fcSpreadFS(fc).KeyMapper(key) - Consistently(func() error { - _, e := os.Stat(dataPath) - return e - }).Should(Succeed(), "the replacement entry's file must survive the failed entry's cleanup") - - s3, err := fc.Get(context.Background(), &testArg{"deferred"}) - Expect(err).ToNot(HaveOccurred()) - Expect(io.ReadAll(s3)).To(Equal([]byte("GOOD"))) - _ = s3.Close() - Expect(n.Load()).To(Equal(int32(2)), "the third Get must be served from cache") - }) - - It("re-fetches when an adopted entry's data file vanished", func() { - // Entries adopted on startup take a different code path than in-process ones. - var calls atomic.Int32 - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - calls.Add(1) - return strings.NewReader("payload"), nil - }) - dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"adopted"}).Key()) - Expect(os.MkdirAll(filepath.Dir(dataPath), 0755)).To(Succeed()) - Expect(os.WriteFile(dataPath, []byte("payload"), 0600)).To(Succeed()) - Expect(fcSpreadFS(fc).MarkComplete(dataPath)).To(Succeed()) - - adopted := callNewFileCache("test2", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - calls.Add(1) - return strings.NewReader("payload"), nil - }) - Expect(os.Remove(dataPath)).To(Succeed()) - - s, err := adopted.Get(context.Background(), &testArg{"adopted"}) - Expect(err).ToNot(HaveOccurred()) - Expect(io.ReadAll(s)).To(Equal([]byte("payload"))) - _ = s.Close() - }) - }) }) }) @@ -423,23 +148,3 @@ 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 6c5151abb..47440cc22 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, 100*time.Millisecond), + cache.NewFileHaunter("", maxItems, maxSize, 300*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,39 +46,24 @@ var _ = Describe("FileHaunter", func() { }) It("removes files", func() { - // 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()) + 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") }) }) - Context("When maxItems is defined", func() { + XContext("When maxItems is defined", func() { BeforeEach(func() { maxItems = 3 }) It("removes files", func() { - // 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()) + 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") }) }) }) @@ -108,26 +93,6 @@ 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 494451c9e..2f0ff4764 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -5,11 +5,11 @@ import ( "errors" "fmt" "runtime" - "sync" "sync/atomic" "time" "github.com/jellydator/ttlcache/v3" + . "github.com/navidrome/navidrome/utils/gg" ) type SimpleCache[K comparable, V any] interface { @@ -45,8 +45,7 @@ func NewSimpleCache[K comparable, V any](options ...Options) SimpleCache[K, V] { c := ttlcache.New[K, V](opts...) cache := &simpleCache[K, V]{ - data: c, - loads: make(map[K]*flight[V]), + data: c, } go cache.data.Start() @@ -63,23 +62,6 @@ const evictionTimeout = 1 * time.Hour type simpleCache[K comparable, V any] struct { data *ttlcache.Cache[K, V] evictionDeadline atomic.Pointer[time.Time] - loadsMu sync.Mutex - loads map[K]*flight[V] -} - -// flight tracks an in-progress load so concurrent misses of the same key share it. -type flight[V any] struct { - done chan struct{} - val V - err error -} - -func (f *flight[V]) result() (V, error) { - if f.err != nil { - var zero V - return zero, fmt.Errorf("cache error: loader returned %w", f.err) - } - return f.val, nil } func (c *simpleCache[K, V]) Add(key K, value V) error { @@ -109,51 +91,35 @@ func (c *simpleCache[K, V]) Get(key K) (V, error) { return item.Value(), nil } -// GetWithLoader loads misses via the loader, deduplicating concurrent loads of -// the same key: one loader call runs, and every waiter shares its result (or error). func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) { - if item := c.data.Get(key); item != nil { - return item.Value(), nil - } - - c.loadsMu.Lock() - if f, ok := c.loads[key]; ok { - c.loadsMu.Unlock() - <-f.done - return f.result() - } - f := &flight[V]{done: make(chan struct{}), err: errLoaderPanicked} - c.loads[key] = f - c.loadsMu.Unlock() - - // Deregister even if the loader panics, so waiters get an error instead of - // blocking forever on a flight that will never complete. - defer func() { - close(f.done) - c.loadsMu.Lock() - delete(c.loads, key) - c.loadsMu.Unlock() - }() - - if item := c.data.Get(key); item != nil { // a flight may have completed since the miss - f.val, f.err = item.Value(), nil - } else { - c.evictExpired() - var ttl time.Duration - f.val, ttl, f.err = loader(key) - if f.err == nil { - c.data.Set(key, f.val, ttl) + var err error + loaderWrapper := ttlcache.LoaderFunc[K, V]( + func(t *ttlcache.Cache[K, V], key K) *ttlcache.Item[K, V] { + c.evictExpired() + var value V + var ttl time.Duration + value, ttl, err = loader(key) + if err != nil { + return nil + } + return t.Set(key, value, ttl) + }, + ) + item := c.data.Get(key, ttlcache.WithLoader[K, V](loaderWrapper)) + if item == nil { + var zero V + if err != nil { + return zero, fmt.Errorf("cache error: loader returned %w", err) } + return zero, errors.New("item not found") } - return f.result() + return item.Value(), nil } -var errLoaderPanicked = errors.New("loader panicked") - func (c *simpleCache[K, V]) evictExpired() { if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) { c.data.DeleteExpired() - c.evictionDeadline.Store(new(time.Now().Add(evictionTimeout))) + c.evictionDeadline.Store(P(time.Now().Add(evictionTimeout))) } } diff --git a/utils/cache/simple_cache_test.go b/utils/cache/simple_cache_test.go index 1c4f5c9bb..45ba2c966 100644 --- a/utils/cache/simple_cache_test.go +++ b/utils/cache/simple_cache_test.go @@ -3,8 +3,6 @@ package cache import ( "errors" "fmt" - "sync" - "sync/atomic" "time" . "github.com/onsi/ginkgo/v2" @@ -71,116 +69,6 @@ var _ = Describe("SimpleCache", func() { _, err := cache.GetWithLoader("key", loader) Expect(err).To(HaveOccurred()) }) - - It("suppresses concurrent loads for the same key", func() { - var calls atomic.Int32 - release := make(chan struct{}) - started := make(chan struct{}, 10) - loader := func(key string) (string, time.Duration, error) { - calls.Add(1) - started <- struct{}{} - <-release - return "shared", time.Minute, nil - } - - const n = 5 - var wg sync.WaitGroup - results := make([]string, n) - errs := make([]error, n) - for i := range n { - wg.Go(func() { - results[i], errs[i] = cache.GetWithLoader("key", loader) - }) - } - - Eventually(started).Should(Receive()) - Consistently(started).ShouldNot(Receive()) - close(release) - wg.Wait() - - Expect(calls.Load()).To(Equal(int32(1))) - for i := range n { - Expect(errs[i]).ToNot(HaveOccurred()) - Expect(results[i]).To(Equal("shared")) - } - }) - - It("returns the loader error to all concurrent callers", func() { - release := make(chan struct{}) - started := make(chan struct{}, 10) - loader := func(key string) (string, time.Duration, error) { - started <- struct{}{} - <-release - return "", 0, errors.New("load failed") - } - - const n = 3 - var wg sync.WaitGroup - errs := make([]error, n) - for i := range n { - wg.Go(func() { - _, errs[i] = cache.GetWithLoader("key", loader) - }) - } - - Eventually(started).Should(Receive()) - Consistently(started).ShouldNot(Receive()) - close(release) - wg.Wait() - - for i := range n { - Expect(errs[i]).To(MatchError(ContainSubstring("load failed"))) - } - }) - - It("supports interface value types with nil results", func() { - c := NewSimpleCache[string, any]() - v, err := c.GetWithLoader("key", func(string) (any, time.Duration, error) { - return nil, time.Minute, nil - }) - Expect(err).ToNot(HaveOccurred()) - Expect(v).To(BeNil()) - }) - - It("cleans up the in-flight registration when the loader panics", func() { - Expect(func() { - _, _ = cache.GetWithLoader("key", func(string) (string, time.Duration, error) { - panic("boom") - }) - }).To(PanicWith("boom")) - - // Without cleanup this would deadlock on the never-completed flight - v, err := cache.GetWithLoader("key", func(string) (string, time.Duration, error) { - return "ok", 0, nil - }) - Expect(err).ToNot(HaveOccurred()) - Expect(v).To(Equal("ok")) - }) - - It("loads different keys independently", func() { - release := make(chan struct{}) - started := make(chan struct{}, 10) - loader := func(key string) (string, time.Duration, error) { - started <- struct{}{} - <-release - return key + "=value", time.Minute, nil - } - - var wg sync.WaitGroup - for _, key := range []string{"key1", "key2"} { - wg.Go(func() { - value, err := cache.GetWithLoader(key, loader) - Expect(err).ToNot(HaveOccurred()) - Expect(value).To(Equal(key + "=value")) - }) - } - - // Both loaders must be in flight at once: distinct keys are not suppressed - Eventually(started).Should(Receive()) - Eventually(started).Should(Receive()) - close(release) - wg.Wait() - }) }) Describe("Keys and Values", func() { diff --git a/utils/cache/spread_fs.go b/utils/cache/spread_fs.go index 11801b324..281e2bfab 100644 --- a/utils/cache/spread_fs.go +++ b/utils/cache/spread_fs.go @@ -14,9 +14,6 @@ import ( "github.com/navidrome/navidrome/log" ) -const completeMarkerSuffix = ".complete" -const sentinelName = ".nd-migrated" - type spreadFS struct { root string mode os.FileMode @@ -43,83 +40,30 @@ 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 := 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 { + err := filepath.WalkDir(sfs.root, func(absoluteFilePath string, de 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 } - visit(absoluteFilePath) + f(absoluteFilePath, absoluteFilePath) + count++ 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) { @@ -128,34 +72,14 @@ func (sfs *spreadFS) Create(name string) (stream.File, error) { if err != nil { return nil, err } - return createDataFile(name) + return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) } 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 d0b54567d..2768ea2d5 100644 --- a/utils/cache/spread_fs_test.go +++ b/utils/cache/spread_fs_test.go @@ -1,13 +1,10 @@ package cache import ( - "io" "os" "path/filepath" - "runtime" "strings" - "github.com/djherbis/stream" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -42,126 +39,31 @@ var _ = Describe("Spread FS", func() { }) }) - Describe("Create", func() { - It("leaves an already-open reader's bytes intact", func() { - // Shrinking a file an older stream still serves spins its reader at a premature EOF. - if runtime.GOOS == "windows" { - Skip("Windows cannot unlink a file with open handles, so Create reuses the inode") - } - name := filepath.Join(rootDir, "aa", "bb", "data") - s, err := stream.NewStream(name, fs) - Expect(err).To(BeNil()) - _, err = s.Write([]byte("PARTIAL")) - Expect(err).To(BeNil()) - r, err := s.NextReader() - Expect(err).To(BeNil()) - Expect(s.Close()).To(Succeed()) - - f, err := fs.Create(name) - Expect(err).To(BeNil()) - _, err = f.Write([]byte("GOOD")) - Expect(err).To(BeNil()) - Expect(f.Close()).To(Succeed()) - - done := make(chan []byte, 1) - go func() { - b, _ := io.ReadAll(r) - done <- b - }() - Eventually(done).Should(Receive(Equal([]byte("PARTIAL")))) - Expect(r.Close()).To(Succeed()) - - Expect(os.ReadFile(name)).To(Equal([]byte("GOOD"))) - }) - }) - - 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() - - Expect(fs.MarkComplete(data)).To(Succeed()) - _, statErr := os.Stat(data + ".complete") - Expect(statErr).To(BeNil()) - }) - - 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 - } + var files []string - 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 + 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() } + }) + It("loads all files from fs", func() { var actual []string - err := fs.Reload(func(key, name string) { + err := fs.Reload(func(key string, name string) { Expect(key).To(Equal(name)) - data, _ := os.ReadFile(name) + data, err := os.ReadFile(name) + Expect(err).To(BeNil()) actual = append(actual, string(data)) }) Expect(err).To(BeNil()) - 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()) + Expect(actual).To(HaveLen(len(files))) + Expect(actual).To(ContainElements(files[0], files[1], files[2])) }) }) }) diff --git a/utils/cache/spread_fs_unix.go b/utils/cache/spread_fs_unix.go deleted file mode 100644 index c7cb19cf8..000000000 --- a/utils/cache/spread_fs_unix.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build !windows - -package cache - -import ( - "os" - - "github.com/djherbis/stream" -) - -// createDataFile unlinks instead of truncating, so a re-created entry gets a fresh -// inode and readers still holding the old one see its full contents. -func createDataFile(name string) (stream.File, error) { - if err := os.Remove(name); err != nil && !os.IsNotExist(err) { - return nil, err - } - return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) -} diff --git a/utils/cache/spread_fs_windows.go b/utils/cache/spread_fs_windows.go deleted file mode 100644 index ca4186d65..000000000 --- a/utils/cache/spread_fs_windows.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build windows - -package cache - -import ( - "os" - - "github.com/djherbis/stream" -) - -// createDataFile truncates in place: Windows refuses to unlink a file another handle -// has open, and failing the create would break every miss on a path with a live reader. -func createDataFile(name string) (stream.File, error) { - return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) -} diff --git a/utils/chrono/meter.go b/utils/chrono/meter.go index 2a249f455..7b4786ed5 100644 --- a/utils/chrono/meter.go +++ b/utils/chrono/meter.go @@ -2,6 +2,8 @@ package chrono import ( "time" + + . "github.com/navidrome/navidrome/utils/gg" ) // Meter is a simple stopwatch @@ -11,7 +13,7 @@ type Meter struct { } func (m *Meter) Start() { - m.mark = new(time.Now()) + m.mark = P(time.Now()) } func (m *Meter) Stop() time.Duration { diff --git a/utils/gg/gg.go b/utils/gg/gg.go index 837f56339..208fe2952 100644 --- a/utils/gg/gg.go +++ b/utils/gg/gg.go @@ -1,6 +1,11 @@ // 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 { @@ -16,13 +21,3 @@ 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 bb6fae867..1d6dff484 100644 --- a/utils/gg/gg_test.go +++ b/utils/gg/gg_test.go @@ -16,9 +16,22 @@ 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() { - Expect(gg.V(new(123))).To(Equal(123)) + v := 123 + Expect(gg.V(&v)).To(Equal(123)) }) It("returns a zero value if the input pointer is nil", func() { @@ -46,25 +59,4 @@ 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/httpclient/httpclient.go b/utils/httpclient/httpclient.go deleted file mode 100644 index b0e7b5681..000000000 --- a/utils/httpclient/httpclient.go +++ /dev/null @@ -1,84 +0,0 @@ -// Package httpclient provides a shared http.Client factory that identifies -// Navidrome via the User-Agent header on all outgoing requests. -package httpclient - -import ( - "context" - "net" - "net/http" - "net/netip" - "net/url" - "time" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/utils/netguard" -) - -type uaTransport struct { - base http.RoundTripper -} - -func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) { - if _, ok := req.Header["User-Agent"]; !ok { - req = req.Clone(req.Context()) - req.Header.Set("User-Agent", consts.HTTPUserAgent) - } - return t.base.RoundTrip(req) -} - -// NewTransport wraps base (or http.DefaultTransport if nil) to set the -// Navidrome User-Agent on requests that don't have one. -func NewTransport(base http.RoundTripper) http.RoundTripper { - if base == nil { - base = http.DefaultTransport - } - return &uaTransport{base: base} -} - -func New(timeout time.Duration) *http.Client { - return &http.Client{Timeout: timeout, Transport: NewTransport(nil)} -} - -// proxyFunc resolves the proxy for a request; tests replace it. -var proxyFunc = http.ProxyFromEnvironment - -type proxyAddrKey struct{} - -// NewExternal is New for URLs from untrusted sources: it refuses to dial private, loopback, -// link-local and unspecified addresses, except those covered by allowed. -func NewExternal(timeout time.Duration, allowed ...netip.Prefix) *http.Client { - t := http.DefaultTransport.(*http.Transport).Clone() - t.Proxy = proxyFunc - direct := net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} - guarded := direct - guarded.Control = netguard.DialControl(allowed...) - t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - // Exempt the hop to the proxy, which relays the request and is operator config. A URL - // aimed at the proxy's own address is not proxied, so it stays guarded. - if proxy, ok := ctx.Value(proxyAddrKey{}).(string); ok && proxy == addr { - return direct.DialContext(ctx, network, addr) - } - return guarded.DialContext(ctx, network, addr) - } - return &http.Client{Timeout: timeout, Transport: NewTransport(&proxyTagger{base: t})} -} - -// proxyTagger records the proxy each request resolves to, so the dialer can tell a hop to the -// proxy from a dial to the URL's own host. -type proxyTagger struct{ base http.RoundTripper } - -func (p *proxyTagger) RoundTrip(req *http.Request) (*http.Response, error) { - if u, err := proxyFunc(req); err == nil && u != nil { - req = req.WithContext(context.WithValue(req.Context(), proxyAddrKey{}, proxyAddr(u))) - } - return p.base.RoundTrip(req) -} - -// proxyAddr mirrors how net/http addresses a proxy connection. -func proxyAddr(u *url.URL) string { - port := u.Port() - if port == "" { - port = map[string]string{"http": "80", "https": "443", "socks5": "1080", "socks5h": "1080"}[u.Scheme] - } - return net.JoinHostPort(u.Hostname(), port) -} diff --git a/utils/httpclient/httpclient_proxy_internal_test.go b/utils/httpclient/httpclient_proxy_internal_test.go deleted file mode 100644 index 2d542d8fe..000000000 --- a/utils/httpclient/httpclient_proxy_internal_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package httpclient - -import ( - "net/http" - "net/http/httptest" - "net/url" - "sync/atomic" - "time" - - "github.com/navidrome/navidrome/utils/netguard" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("NewExternal with a proxy", func() { - var proxy *httptest.Server - var proxied atomic.Int32 - var proxyURL *url.URL - - BeforeEach(func() { - proxied.Store(0) - proxy = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - proxied.Add(1) - _, _ = w.Write([]byte("via proxy")) - })) - DeferCleanup(proxy.Close) - proxyURL, _ = url.Parse(proxy.URL) - prev := proxyFunc - DeferCleanup(func() { proxyFunc = prev }) - }) - - It("dials the configured proxy even though it listens on a private address", func() { - proxyFunc = func(*http.Request) (*url.URL, error) { return proxyURL, nil } - - resp, err := NewExternal(time.Second).Get("http://navidrome.example.com/cover.jpg") - Expect(err).ToNot(HaveOccurred()) - defer resp.Body.Close() - Expect(proxied.Load()).To(Equal(int32(1))) - }) - - // net/http never proxies loopback targets, so a URL aimed at a loopback proxy is dialed directly. - It("refuses a direct dial to the proxy's own address when the request is not proxied", func() { - proxyFunc = func(r *http.Request) (*url.URL, error) { - if r.URL.Hostname() == "127.0.0.1" { - return nil, nil - } - return proxyURL, nil - } - - _, err := NewExternal(time.Second).Get(proxy.URL + "/secret") - Expect(err).To(MatchError(netguard.ErrPrivateAddress)) - Expect(proxied.Load()).To(BeZero()) - }) - - It("still refuses a direct dial to a private address", func() { - proxyFunc = func(r *http.Request) (*url.URL, error) { - if r.URL.Scheme == "https" { - return proxyURL, nil - } - return nil, nil - } - target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - DeferCleanup(target.Close) - - _, err := NewExternal(time.Second).Get(target.URL) - Expect(err).To(MatchError(netguard.ErrPrivateAddress)) - }) -}) diff --git a/utils/httpclient/httpclient_suite_test.go b/utils/httpclient/httpclient_suite_test.go deleted file mode 100644 index e18a9d0ad..000000000 --- a/utils/httpclient/httpclient_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package httpclient_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestHTTPClient(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "HTTPClient Suite") -} diff --git a/utils/httpclient/httpclient_test.go b/utils/httpclient/httpclient_test.go deleted file mode 100644 index 7b1e58797..000000000 --- a/utils/httpclient/httpclient_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package httpclient_test - -import ( - "net/http" - "net/http/httptest" - "net/netip" - "time" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/utils/httpclient" - "github.com/navidrome/navidrome/utils/netguard" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("httpclient", func() { - var server *httptest.Server - var receivedUA string - - BeforeEach(func() { - receivedUA = "" - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedUA = r.Header.Get("User-Agent") - })) - DeferCleanup(server.Close) - }) - - Describe("New", func() { - It("sets the Navidrome User-Agent when the request has none", func() { - c := httpclient.New(time.Second) - resp, err := c.Get(server.URL) - Expect(err).ToNot(HaveOccurred()) - resp.Body.Close() - Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) - }) - - It("keeps a User-Agent already set by the caller", func() { - c := httpclient.New(time.Second) - req, err := http.NewRequest(http.MethodGet, server.URL, nil) - Expect(err).ToNot(HaveOccurred()) - req.Header.Set("User-Agent", "CustomAgent/1.0") - resp, err := c.Do(req) - Expect(err).ToNot(HaveOccurred()) - resp.Body.Close() - Expect(receivedUA).To(Equal("CustomAgent/1.0")) - }) - - It("applies the given timeout", func() { - c := httpclient.New(5 * time.Second) - Expect(c.Timeout).To(Equal(5 * time.Second)) - }) - }) - - Describe("NewExternal", func() { - It("refuses to connect to a loopback server", func() { - c := httpclient.NewExternal(time.Second) - _, err := c.Get(server.URL) - Expect(err).To(MatchError(netguard.ErrPrivateAddress)) - Expect(receivedUA).To(BeEmpty()) - }) - - It("refuses a redirect from an allowed host to a private address", func() { - redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound) - })) - DeferCleanup(redirector.Close) - - c := httpclient.NewExternal(time.Second, netip.MustParsePrefix("127.0.0.1/32")) - _, err := c.Get(redirector.URL) - Expect(err).To(MatchError(netguard.ErrPrivateAddress)) - }) - - It("connects to addresses covered by an allowed prefix and sets the User-Agent", func() { - c := httpclient.NewExternal(time.Second, netip.MustParsePrefix("127.0.0.0/8")) - resp, err := c.Get(server.URL) - Expect(err).ToNot(HaveOccurred()) - resp.Body.Close() - Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) - }) - }) - - Describe("NewTransport", func() { - It("uses the default transport when base is nil", func() { - c := &http.Client{Transport: httpclient.NewTransport(nil)} - resp, err := c.Get(server.URL) - Expect(err).ToNot(HaveOccurred()) - resp.Body.Close() - Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) - }) - - It("does not modify the original request", func() { - c := &http.Client{Transport: httpclient.NewTransport(nil)} - req, err := http.NewRequest(http.MethodGet, server.URL, nil) - Expect(err).ToNot(HaveOccurred()) - resp, err := c.Do(req) - Expect(err).ToNot(HaveOccurred()) - resp.Body.Close() - Expect(req.Header).ToNot(HaveKey("User-Agent")) - }) - }) - - Describe("HTTPUserAgent", func() { - It("identifies Navidrome with version and project URL", func() { - Expect(consts.HTTPUserAgent).To(Equal("Navidrome/" + consts.Version + " - https://github.com/navidrome")) - }) - }) -}) diff --git a/utils/natural/natural.go b/utils/natural/natural.go index d8ddcc405..fa0800e1d 100644 --- a/utils/natural/natural.go +++ b/utils/natural/natural.go @@ -10,32 +10,15 @@ import "strings" // or a positive value if a > b using natural sort ordering. // // When two numeric segments are numerically equal (e.g. "01" vs "1"), -// comparison continues with the remaining suffixes, and the padding -// difference is kept as a final tie-breaker that only decides strings -// that are otherwise equal (e.g. "a01" < "a1", "a0" < "a00"). Deferring -// it that way is what keeps the ordering transitive, which SQLite -// requires of a collating function. +// comparison continues with the remaining suffixes. If one or both +// strings end at the digit boundary, the raw strings are compared +// lexically, which makes leading zeros significant as a tie-breaker +// (e.g. "a01" < "a1", "a0" < "a00"). func Compare(a, b string) int { - return compare(a, b, false) -} - -// CompareFold is Compare with ASCII case folding, matching SQLite's NOCASE -// collation: only A-Z fold, bytes >= 0x80 are compared as-is. -func CompareFold(a, b string) int { - return compare(a, b, true) -} - -func compare(a, b string, fold bool) int { ia, ib := 0, 0 - // Set when two runs are numerically equal but differently padded. Applying it - // immediately would break transitivity, so it only decides otherwise-equal strings. - padTie := 0 for ia < len(a) && ib < len(b) { ca, cb := a[ia], b[ib] da, db := isDigit(ca), isDigit(cb) - if fold { - ca, cb = lower(ca), lower(cb) - } switch { case da && db: @@ -52,11 +35,17 @@ func compare(a, b string, fold bool) int { if c := compareNumbers(a[ia:endA], b[ib:endB]); c != 0 { return c } - if t := strings.Compare(a[ia:endA], b[ib:endB]); t != 0 { - padTie = t + + // Numerically equal. If both sides have trailing data, continue + // comparing after the digit runs. Otherwise fall through to + // lexical comparison of the full remaining strings (which makes + // leading-zero differences significant as a tie-breaker). + if endA < len(a) && endB < len(b) { + ia = endA + ib = endB + continue } - ia = endA - ib = endB + return strings.Compare(a[ia:], b[ib:]) case da != db: return int(ca) - int(cb) default: @@ -67,10 +56,7 @@ func compare(a, b string, fold bool) int { ib++ } } - if c := (len(a) - ia) - (len(b) - ib); c != 0 { - return c - } - return padTie + return (len(a) - ia) - (len(b) - ib) } // compareNumbers compares two digit strings numerically. @@ -110,10 +96,3 @@ func stripZeros(s string) string { func isDigit(c byte) bool { return c >= '0' && c <= '9' } - -func lower(c byte) byte { - if c >= 'A' && c <= 'Z' { - return c + 'a' - 'A' - } - return c -} diff --git a/utils/natural/natural_test.go b/utils/natural/natural_test.go index 534885d40..825a944c0 100644 --- a/utils/natural/natural_test.go +++ b/utils/natural/natural_test.go @@ -13,23 +13,17 @@ func TestNatural(t *testing.T) { RunSpecs(t, "Natural Suite") } -// expectOrder asserts the sign of cmp(a, b) matches expected. -func expectOrder(cmp func(string, string) int, a, b string, expected int) { - result := cmp(a, b) - switch { - case expected < 0: - ExpectWithOffset(1, result).To(BeNumerically("<", 0), "expected %q < %q", a, b) - case expected > 0: - ExpectWithOffset(1, result).To(BeNumerically(">", 0), "expected %q > %q", a, b) - default: - ExpectWithOffset(1, result).To(Equal(0), "expected %q == %q", a, b) - } -} - var _ = Describe("Compare", func() { DescribeTable("returns correct ordering", func(a, b string, expected int) { - expectOrder(natural.Compare, a, b, expected) + result := natural.Compare(a, b) + if expected < 0 { + Expect(result).To(BeNumerically("<", 0), "expected %q < %q", a, b) + } else if expected > 0 { + Expect(result).To(BeNumerically(">", 0), "expected %q > %q", a, b) + } else { + Expect(result).To(Equal(0), "expected %q == %q", a, b) + } }, // Basic string ordering Entry("a < b", "a", "b", -1), @@ -73,9 +67,7 @@ var _ = Describe("Compare", func() { Entry("a00b00 < a0b1", "a00b00", "a0b1", -1), Entry("a00b00 > a0b0", "a00b00", "a0b0", 1), Entry("a00b01 > a0b00", "a00b01", "a0b00", 1), - // Distinct strings must not compare equal: the padding difference in the first - // run decides once everything else matches. - Entry("a00b00 > a0b00", "a00b00", "a0b00", 1), + Entry("a00b00 == a0b00", "a00b00", "a0b00", 0), // Leading zeros at end of string — lexical tie-break Entry("file01 < file1", "file01", "file1", -1), @@ -117,78 +109,8 @@ var _ = Describe("Compare", func() { Entry("large: equal", "a100000000000000000000", "a100000000000000000000", 0), Entry("large: leading zeros with trailing data", - "a00000000000000000000001x", "a1x", -1), + "a00000000000000000000001x", "a1x", 0), Entry("large: leading zeros with trailing data (2)", - "a099999999999999999999x", "a99999999999999999999x", -1), + "a099999999999999999999x", "a99999999999999999999x", 0), ) }) - -var _ = Describe("CompareFold", func() { - DescribeTable("orders case-insensitively", - func(a, b string, expected int) { - expectOrder(natural.CompareFold, a, b, expected) - }, - Entry("numbers compare numerically", "foo 2", "foo 10", -1), - Entry("numbers compare numerically, reversed", "foo 10", "foo 2", 1), - Entry("case is ignored", "apple 2", "Banana 10", -1), - Entry("case is ignored, reversed", "Banana 10", "apple 2", 1), - Entry("same word, different case, is equal", "ABC", "abc", 0), - Entry("case ignored while comparing numbers", "Vol 2", "vol 10", -1), - Entry("uppercase digits boundary", "Track9", "track10", -1), - Entry("empty vs empty", "", "", 0), - Entry("empty sorts first", "", "a", -1), - Entry("non-ASCII is left untouched", "café 2", "café 10", -1), - ) - - // SQLite requires a collating function to be transitive; if it is not, the behavior of - // ORDER BY is undefined and paginated queries can drop or duplicate rows. - It("is transitive, as a SQLite collation requires", func() { - var corpus []string - var build func(prefix string, depth int) - build = func(prefix string, depth int) { - if prefix != "" { - corpus = append(corpus, prefix) - } - if depth == 0 { - return - } - for _, c := range []string{"0", "1", "a"} { - build(prefix+c, depth-1) - } - } - build("", 3) - - sign := func(n int) int { - switch { - case n < 0: - return -1 - case n > 0: - return 1 - } - return 0 - } - for _, a := range corpus { - for _, b := range corpus { - ab := sign(natural.CompareFold(a, b)) - for _, c := range corpus { - bc := sign(natural.CompareFold(b, c)) - ac := sign(natural.CompareFold(a, c)) - if ab == 0 && bc == 0 { - Expect(ac).To(Equal(0), "%q==%q and %q==%q but %q vs %q is %d", a, b, b, c, a, c, ac) - } - if ab < 0 && bc < 0 { - Expect(ac).To(BeNumerically("<", 0), "%q<%q<%q but %q vs %q is %d", a, b, c, a, c, ac) - } - } - } - } - }) - - It("matches Compare when both sides are already lowercase", func() { - pairs := [][2]string{{"foo 2", "foo 10"}, {"a01", "a1"}, {"a", "aa"}, {"vol 3", "vol 3"}} - for _, p := range pairs { - Expect(natural.CompareFold(p[0], p[1])).To(Equal(natural.Compare(p[0], p[1])), - "CompareFold(%q,%q) should match Compare", p[0], p[1]) - } - }) -}) diff --git a/utils/netguard/netguard.go b/utils/netguard/netguard.go deleted file mode 100644 index 43d80ecc9..000000000 --- a/utils/netguard/netguard.go +++ /dev/null @@ -1,38 +0,0 @@ -// Package netguard keeps outbound connections driven by untrusted input away from internal addresses. -package netguard - -import ( - "errors" - "fmt" - "net" - "net/netip" - "syscall" -) - -var ErrPrivateAddress = errors.New("dial to private/loopback address blocked") - -// IsPrivateIP reports whether ip is loopback, private, link-local or unspecified. -func IsPrivateIP(ip net.IP) bool { - return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() -} - -// DialControl returns a net.Dialer Control hook that rejects private addresses not covered by allowed. -// It sees the resolved IP, so DNS names, redirects and rebinding cannot get around it. -func DialControl(allowed ...netip.Prefix) func(network, address string, c syscall.RawConn) error { - return func(_, address string, _ syscall.RawConn) error { - ap, err := netip.ParseAddrPort(address) - if err != nil { - return fmt.Errorf("%w: unparseable address %q: %w", ErrPrivateAddress, address, err) - } - addr := ap.Addr().Unmap() - if !IsPrivateIP(addr.AsSlice()) { - return nil - } - for _, p := range allowed { - if p.Contains(addr) { - return nil - } - } - return fmt.Errorf("%w: %s", ErrPrivateAddress, address) - } -} diff --git a/utils/netguard/netguard_suite_test.go b/utils/netguard/netguard_suite_test.go deleted file mode 100644 index 7769e0c82..000000000 --- a/utils/netguard/netguard_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package netguard_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestNetguard(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Netguard Suite") -} diff --git a/utils/netguard/netguard_test.go b/utils/netguard/netguard_test.go deleted file mode 100644 index 733204226..000000000 --- a/utils/netguard/netguard_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package netguard_test - -import ( - "net" - "net/netip" - - "github.com/navidrome/navidrome/utils/netguard" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("netguard", func() { - DescribeTable("IsPrivateIP", - func(addr string, expected bool) { - Expect(netguard.IsPrivateIP(net.ParseIP(addr))).To(Equal(expected)) - }, - Entry("IPv4 loopback", "127.0.0.1", true), - Entry("IPv4 loopback range", "127.0.0.2", true), - Entry("IPv6 loopback", "::1", true), - Entry("IPv4-mapped IPv6 loopback", "::ffff:127.0.0.1", true), - Entry("10.x", "10.1.2.3", true), - Entry("172.16.x", "172.16.0.1", true), - Entry("192.168.x", "192.168.1.10", true), - Entry("IPv6 unique local", "fd00::1", true), - Entry("link-local (cloud metadata)", "169.254.169.254", true), - Entry("IPv6 link-local", "fe80::1", true), - Entry("link-local multicast", "224.0.0.1", true), - Entry("IPv4 unspecified", "0.0.0.0", true), - Entry("IPv6 unspecified", "::", true), - Entry("public IPv4", "93.184.216.34", false), - Entry("172.32.x is outside the private block", "172.32.0.1", false), - Entry("public IPv6", "2606:4700:4700::1111", false), - ) - - Describe("DialControl", func() { - It("rejects private and loopback addresses", func() { - control := netguard.DialControl() - for _, addr := range []string{"127.0.0.1:80", "[::1]:443", "169.254.169.254:80", "10.0.0.1:8080", "0.0.0.0:80"} { - Expect(control("tcp", addr, nil)).To(MatchError(netguard.ErrPrivateAddress), addr) - } - }) - - It("allows public addresses", func() { - control := netguard.DialControl() - Expect(control("tcp", "93.184.216.34:443", nil)).To(Succeed()) - Expect(control("tcp6", "[2606:4700:4700::1111]:443", nil)).To(Succeed()) - }) - - It("allows private addresses covered by an allowed prefix, and only those", func() { - control := netguard.DialControl(netip.MustParsePrefix("127.0.0.1/32")) - Expect(control("tcp", "127.0.0.1:8080", nil)).To(Succeed()) - Expect(control("tcp", "127.0.0.2:8080", nil)).To(MatchError(netguard.ErrPrivateAddress)) - }) - - It("matches IPv4-mapped IPv6 addresses against IPv4 prefixes", func() { - control := netguard.DialControl(netip.MustParsePrefix("127.0.0.0/8")) - Expect(control("tcp", "[::ffff:127.0.0.1]:80", nil)).To(Succeed()) - }) - - It("fails closed when the address is not an IP", func() { - Expect(netguard.DialControl()("tcp", "localhost:80", nil)).To(HaveOccurred()) - Expect(netguard.DialControl()("tcp", "garbage", nil)).To(HaveOccurred()) - }) - }) -}) diff --git a/utils/req/req.go b/utils/req/req.go index 6a863184c..6b6135e1a 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -38,7 +38,8 @@ 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 { - v = new(r.URL.Query().Get(param)) + s := r.URL.Query().Get(param) + v = &s } return v } @@ -47,7 +48,8 @@ func (r *Values) BoolPtr(param string) *bool { var v *bool if _, exists := r.URL.Query()[param]; exists { s := r.URL.Query().Get(param) - v = new(strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/")) + b := strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/") + v = &b } return v } @@ -60,10 +62,12 @@ func (r *Values) StringOr(param, def string) string { return v } -// Strings returns all occurrences of the param, or a nil (empty) slice when absent. Callers that -// require the param should check for emptiness themselves. -func (r *Values) Strings(param string) []string { - return r.URL.Query()[param] +func (r *Values) Strings(param string) ([]string, error) { + values := r.URL.Query()[param] + if len(values) == 0 { + return nil, newError(ErrMissingParam, param) + } + return values, nil } func (r *Values) TimeOr(param string, def time.Time) time.Time { @@ -83,9 +87,9 @@ func (r *Values) TimeOr(param string, def time.Time) time.Time { } func (r *Values) Times(param string) ([]time.Time, error) { - pStr := r.Strings(param) - if len(pStr) == 0 { - return nil, newError(ErrMissingParam, param) + pStr, err := r.Strings(param) + if err != nil { + return nil, err } times := make([]time.Time, len(pStr)) for i, t := range pStr { @@ -137,9 +141,9 @@ func (r *Values) Int64Or(param string, def int64) int64 { } func (r *Values) Ints(param string) ([]int, error) { - pStr := r.Strings(param) - if len(pStr) == 0 { - return nil, newError(ErrMissingParam, param) + pStr, err := r.Strings(param) + if err != nil { + return nil, err } ints := make([]int, 0, len(pStr)) for _, s := range pStr { @@ -178,21 +182,3 @@ func (r *Values) Float64Or(param string, def float64) float64 { } return f } - -// IfNoneMatch reports whether the request's If-None-Match asserts etag (unquoted), using RFC 9110 weak comparison. -func IfNoneMatch(r *http.Request, etag string) bool { - header := strings.TrimSpace(r.Header.Get("If-None-Match")) - if header == "" { - return false - } - if header == "*" { - return true - } - for tag := range strings.SplitSeq(header, ",") { - tag = strings.TrimPrefix(strings.TrimSpace(tag), "W/") - if strings.Trim(tag, `"`) == etag { - return true - } - } - return false -} diff --git a/utils/req/req_test.go b/utils/req/req_test.go index d64f51b02..d76b3b934 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -60,7 +60,9 @@ var _ = Describe("Request Helpers", func() { }) It("returns empty array if param does not exist", func() { - Expect(r.Strings("xx")).To(BeEmpty()) + v, err := r.Strings("xx") + Expect(err).To(MatchError(req.ErrMissingParam)) + Expect(v).To(BeEmpty()) }) }) @@ -290,21 +292,3 @@ var _ = Describe("Request Helpers", func() { }) }) }) - -var _ = Describe("IfNoneMatch", func() { - DescribeTable("matches the ETag", - func(header string, expected bool) { - r := httptest.NewRequest("GET", "/", nil) - if header != "" { - r.Header.Set("If-None-Match", header) - } - Expect(req.IfNoneMatch(r, "abc123")).To(Equal(expected)) - }, - Entry("absent header", "", false), - Entry("exact quoted tag", `"abc123"`, true), - Entry("weak tag", `W/"abc123"`, true), - Entry("tag in a list", `"other", W/"abc123"`, true), - Entry("wildcard", "*", true), - Entry("different tag", `"stale"`, false), - ) -}) diff --git a/utils/singleton/singleton.go b/utils/singleton/singleton.go index 8271034ad..83f8c53ab 100644 --- a/utils/singleton/singleton.go +++ b/utils/singleton/singleton.go @@ -67,13 +67,3 @@ func GetInstance[T any](constructor func() T) T { return newInstance } - -// DeleteInstance drops the cached instance of type T so the next GetInstance rebuilds it. -// Intended for tests and benchmarks that need a fresh instance regardless of run order. -func DeleteInstance[T any]() { - var v T - name := reflect.TypeOf(v).String() - lock.Lock() - delete(instances, name) - lock.Unlock() -} diff --git a/utils/slice/slice.go b/utils/slice/slice.go index e3677e754..e87ac5388 100644 --- a/utils/slice/slice.go +++ b/utils/slice/slice.go @@ -33,23 +33,6 @@ func Group[T any, K comparable](s []T, keyFunc func(T) K) map[K][]T { return m } -// GroupOrdered is Group with the groups in first-seen order. -func GroupOrdered[T any, K comparable](s []T, keyFunc func(T) K) [][]T { - var groups [][]T - index := map[K]int{} - for _, item := range s { - k := keyFunc(item) - i, ok := index[k] - if !ok { - i = len(groups) - index[k] = i - groups = append(groups, nil) - } - groups[i] = append(groups[i], item) - } - return groups -} - func ToMap[T any, K comparable, V any](s []T, transformFunc func(T) (K, V)) map[K]V { m := make(map[K]V, len(s)) for _, item := range s { @@ -59,16 +42,6 @@ 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 506b61b9f..65e5f0934 100644 --- a/utils/slice/slice_test.go +++ b/utils/slice/slice_test.go @@ -63,18 +63,6 @@ var _ = Describe("Slice Utils", func() { }) }) - Describe("GroupOrdered", func() { - It("returns nil for an empty input", func() { - Expect(slice.GroupOrdered([]int{}, func(v int) int { return v })).To(BeNil()) - }) - - It("keeps groups in first-seen order and items in input order", func() { - keyFunc := func(v int) int { return v % 3 } - result := slice.GroupOrdered([]int{2, 1, 4, 3, 5, 6, 8}, keyFunc) - Expect(result).To(Equal([][]int{{2, 5, 8}, {1, 4}, {3, 6}})) - }) - }) - Describe("ToMap", func() { It("returns empty map for an empty input", func() { transformFunc := func(v int) (int, string) { return v, strconv.Itoa(v) } @@ -93,20 +81,6 @@ 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()) @@ -160,7 +134,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/content_disposition.go b/utils/str/content_disposition.go deleted file mode 100644 index 34ae8a5c6..000000000 --- a/utils/str/content_disposition.go +++ /dev/null @@ -1,80 +0,0 @@ -package str - -import ( - "cmp" - "fmt" - "mime" - "path" - "strings" - "unicode" - "unicode/utf8" - - "github.com/deluan/sanitize" -) - -const ( - maxFilenameBytes = 255 - maxExtensionBytes = 16 - fallbackFilename = "download" -) - -// ContentDispositionAttachment builds an RFC 6266 attachment header value for a user-controlled filename. -// Non-ASCII names also get a filename* parameter, which clients prefer over the ASCII fallback. -func ContentDispositionAttachment(filename string) string { - stem, ext := splitFilename(filename) - header := fmt.Sprintf("attachment; filename=%q", joinFilename(toASCII(stem), toASCII(ext))) - name := joinFilename(stem, ext) - if isASCII(name) { - return header - } - // FormatMediaType emits a percent-encoded filename* for non-ASCII values - extended := mime.FormatMediaType("attachment", map[string]string{"filename": name}) - return header + strings.TrimPrefix(extended, "attachment") -} - -func splitFilename(filename string) (stem, ext string) { - name := strings.Map(func(r rune) rune { - if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) { - return -1 - } - return r - }, SanitizeFilename(strings.ToValidUTF8(filename, "_"))) - ext = path.Ext(name) - if len(ext) > maxExtensionBytes { - ext = "" - } - stem = strings.TrimSuffix(name, ext) - if limit := maxFilenameBytes - len(ext); len(stem) > limit { - for limit > 0 && !utf8.RuneStart(stem[limit]) { - limit-- - } - stem = stem[:limit] - } - return stem, ext -} - -func joinFilename(stem, ext string) string { - stem = cmp.Or(strings.TrimSpace(stem), fallbackFilename) - return cmp.Or(strings.TrimRight(stem+ext, " ."), fallbackFilename) -} - -func toASCII(s string) string { - return strings.Map(func(r rune) rune { - switch { - case r == ',': - return '_' - case r >= ' ' && r <= '~': - return r - } - return -1 - }, sanitize.Accents(SanitizeFilename(Clear(s)))) -} - -func isASCII(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] >= utf8.RuneSelf { - return false - } - } - return true -} diff --git a/utils/str/content_disposition_test.go b/utils/str/content_disposition_test.go deleted file mode 100644 index 944392a4e..000000000 --- a/utils/str/content_disposition_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package str_test - -import ( - "mime" - "regexp" - "strings" - - "github.com/navidrome/navidrome/utils/str" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var asciiFilenameRe = regexp.MustCompile(`(?:^|; )filename="([^"]*)"`) - -// asciiName returns the quoted filename parameter, the one used by clients that ignore filename*. -func asciiName(filename string) string { - m := asciiFilenameRe.FindAllStringSubmatch(str.ContentDispositionAttachment(filename), -1) - ExpectWithOffset(1, m).To(HaveLen(1), "expected exactly one quoted filename parameter") - return m[0][1] -} - -// decodedName returns the name an RFC 6266 client picks, preferring filename* when present. -func decodedName(filename string) string { - disp, params, err := mime.ParseMediaType(str.ContentDispositionAttachment(filename)) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - ExpectWithOffset(1, disp).To(Equal("attachment")) - return params["filename"] -} - -var _ = Describe("ContentDispositionAttachment", func() { - Describe("parameter injection", func() { - const attack = `party"; filename="evil.html.m3u` - - It("does not let a quote in the name open a second parameter", func() { - Expect(str.ContentDispositionAttachment(attack)).To(Equal(`attachment; filename="party_; filename=_evil.html.m3u"`)) - }) - - It("keeps the header parseable", func() { - Expect(decodedName(attack)).To(Equal("party_; filename=_evil.html.m3u")) - }) - - It("does not let a quote in a non-ASCII name inject either", func() { - const utf8Attack = `東京"; filename="evil.html.m3u` - header := str.ContentDispositionAttachment(utf8Attack) - Expect(strings.Count(header, `"`)).To(Equal(2)) - Expect(decodedName(utf8Attack)).To(Equal("東京_; filename=_evil.html.m3u")) - }) - }) - - Describe("ASCII names", func() { - It("sends only the quoted filename, unchanged", func() { - Expect(str.ContentDispositionAttachment("My Playlist.m3u")).To(Equal(`attachment; filename="My Playlist.m3u"`)) - }) - - It("keeps leading dots", func() { - Expect(asciiName("...And Justice for All.zip")).To(Equal("...And Justice for All.zip")) - }) - - It("trims surrounding spaces and trailing dots", func() { - Expect(asciiName(" Greatest Hits .zip")).To(Equal("Greatest Hits.zip")) - Expect(asciiName("Loose End. ")).To(Equal("Loose End")) - }) - - It("falls back to a placeholder when only dots are left", func() { - Expect(str.ContentDispositionAttachment("...")).To(Equal(`attachment; filename="download"`)) - }) - - It("caps a long name whose last dot is not an extension", func() { - name := asciiName("a." + strings.Repeat("x", 300)) - Expect(len(name)).To(BeNumerically("<=", 255)) - Expect(name).To(HavePrefix("a.xxx")) - }) - - It("replaces path separators and reserved characters", func() { - Expect(asciiName("AC/DC: Live, 1979?.zip")).To(Equal("AC_DC_ Live_ 1979_.zip")) - }) - - It("drops control characters", func() { - Expect(asciiName("line\r\nbreak\x00\t.mp3")).To(Equal("linebreak.mp3")) - }) - }) - - Describe("non-ASCII names", func() { - It("transliterates accents in the ASCII fallback", func() { - Expect(asciiName("Legião Urbana.zip")).To(Equal("Legiao Urbana.zip")) - }) - - It("converts typographic punctuation in the ASCII fallback", func() { - Expect(asciiName("She’s a Woman — Live.mp3")).To(Equal("She's a Woman - Live.mp3")) - Expect(asciiName("“Heroes”.mp3")).To(Equal("_Heroes_.mp3")) - Expect(decodedName("She’s a Woman.mp3")).To(Equal("She’s a Woman.mp3")) - }) - - It("keeps the extension when no ASCII letters survive", func() { - Expect(asciiName("東京.mp3")).To(Equal("download.mp3")) - Expect(asciiName("Кино.m3u")).To(Equal("download.m3u")) - Expect(asciiName("東京")).To(Equal("download")) - }) - - DescribeTable("filename* carries the sanitized UTF-8 name", - func(filename, expected string) { - Expect(decodedName(filename)).To(Equal(expected)) - }, - Entry("accented", "Legião Urbana.zip", "Legião Urbana.zip"), - Entry("CJK", "東京.zip", "東京.zip"), - Entry("emoji", "🎵 mix.zip", "🎵 mix.zip"), - Entry("comma and percent", "Sigur Rós, 100%.zip", "Sigur Rós, 100%.zip"), - Entry("path separators", "Sigur Rós/Live: Heima.zip", "Sigur Rós_Live_ Heima.zip"), - Entry("control characters", "Sigur Rós\r\n\x00.zip", "Sigur Rós.zip"), - Entry("bidi override", "Björk\u202Eexe.mp3", "Björkexe.mp3"), - Entry("invalid UTF-8", "Bj\xf6rk Café.mp3", "Bj_rk Café.mp3"), - ) - - It("caps the name length and keeps the extension", func() { - name := decodedName(strings.Repeat("東", 300) + ".zip") - Expect(len(name)).To(BeNumerically("<=", 255)) - Expect(name).To(HaveSuffix("東.zip")) - }) - }) -}) diff --git a/utils/str/normalize_fts.go b/utils/str/normalize_fts.go deleted file mode 100644 index 994f77cc9..000000000 --- a/utils/str/normalize_fts.go +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 52387cb1d..000000000 --- a/utils/str/normalize_fts_test.go +++ /dev/null @@ -1,29 +0,0 @@ -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"), -) diff --git a/utils/time.go b/utils/time.go index b3f1a98fb..c1e949589 100644 --- a/utils/time.go +++ b/utils/time.go @@ -1,12 +1,6 @@ package utils -import ( - "fmt" - "regexp" - "strconv" - "strings" - "time" -) +import "time" func TimeNewest(times ...time.Time) time.Time { newest := time.Time{} @@ -17,59 +11,3 @@ func TimeNewest(times ...time.Time) time.Time { } return newest } - -var durationDayWeekRe = regexp.MustCompile(`-?\d+(?:\.\d+)?[dw]`) - -// ParseDuration is time.ParseDuration extended with d (24h) and w (168h) units. -// Negative durations are rejected. -func ParseDuration(s string) (time.Duration, error) { - expanded := durationDayWeekRe.ReplaceAllStringFunc(s, func(match string) string { - value, err := strconv.ParseFloat(match[:len(match)-1], 64) - if err != nil { - return match - } - hours := value * 24 - if match[len(match)-1] == 'w' { - hours = value * 24 * 7 - } - return strconv.FormatFloat(hours, 'f', -1, 64) + "h" - }) - d, err := time.ParseDuration(expanded) - if err != nil { - return 0, fmt.Errorf("invalid duration %q: %w", s, err) - } - if d < 0 { - return 0, fmt.Errorf("negative duration not allowed: %q", s) - } - return d, nil -} - -// FormatDuration renders whole w/d multiples with those units, falling back to -// time.Duration.String for the sub-day remainder, so ParseDuration round-trips. -func FormatDuration(d time.Duration) string { - if d < 24*time.Hour { - return formatSubDay(d) - } - var b strings.Builder - weekDuration := 7 * 24 * time.Hour - if weeks := d / weekDuration; weeks > 0 { - b.WriteString(strconv.Itoa(int(weeks)) + "w") - d %= weekDuration - } - dayDuration := 24 * time.Hour - if days := d / dayDuration; days > 0 { - b.WriteString(strconv.Itoa(int(days)) + "d") - d %= dayDuration - } - if d > 0 { - b.WriteString(formatSubDay(d)) - } - return b.String() -} - -func formatSubDay(d time.Duration) string { - if d >= time.Hour && d%time.Hour == 0 { - return strconv.Itoa(int(d/time.Hour)) + "h" - } - return d.String() -} diff --git a/utils/time_test.go b/utils/time_test.go index 8460b98f9..f89f0d2be 100644 --- a/utils/time_test.go +++ b/utils/time_test.go @@ -26,74 +26,3 @@ var _ = Describe("TimeNewest", func() { Expect(utils.TimeNewest(t1, t2, t3)).To(Equal(t2)) }) }) - -var _ = Describe("ParseDuration", func() { - DescribeTable("parses valid durations", - func(input string, expected time.Duration) { - d, err := utils.ParseDuration(input) - Expect(err).ToNot(HaveOccurred()) - Expect(d).To(Equal(expected)) - }, - Entry("standard Go units", "90m", 90*time.Minute), - Entry("hours", "12h", 12*time.Hour), - Entry("days", "1d", 24*time.Hour), - Entry("weeks", "1w", 7*24*time.Hour), - Entry("multiple days", "3d", 72*time.Hour), - Entry("mixed day and hours", "1d12h", 36*time.Hour), - Entry("mixed week, day and hours", "1w2d3h", (7*24+2*24+3)*time.Hour), - Entry("fractional days", "0.5d", 12*time.Hour), - ) - - DescribeTable("rejects invalid durations", - func(input string) { - _, err := utils.ParseDuration(input) - Expect(err).To(HaveOccurred()) - }, - Entry("empty string", ""), - Entry("not a duration", "tomorrow"), - Entry("bare number", "42"), - Entry("unit only", "d"), - Entry("unknown unit", "5y"), - ) - - DescribeTable("rejects negative durations", - func(input string) { - _, err := utils.ParseDuration(input) - Expect(err).To(MatchError(ContainSubstring("negative duration"))) - }, - Entry("negative days", "-1d"), - Entry("negative weeks", "-0.5w"), - Entry("negative Go units", "-30m"), - ) -}) - -var _ = Describe("FormatDuration", func() { - DescribeTable("formats durations using the largest whole units", - func(input time.Duration, expected string) { - Expect(utils.FormatDuration(input)).To(Equal(expected)) - }, - Entry("whole weeks", 7*24*time.Hour, "1w"), - Entry("whole days", 24*time.Hour, "1d"), - Entry("multiple days", 72*time.Hour, "3d"), - Entry("day and hours", 36*time.Hour, "1d12h"), - Entry("week, day and hours", (7*24+2*24+3)*time.Hour, "1w2d3h"), - Entry("hours only", 12*time.Hour, "12h"), - Entry("sub-hour", 90*time.Minute, "1h30m0s"), - Entry("zero", time.Duration(0), "0s"), - ) - - DescribeTable("round-trips through ParseDuration", - func(input string) { - d, err := utils.ParseDuration(input) - Expect(err).ToNot(HaveOccurred()) - formatted := utils.FormatDuration(d) - d2, err := utils.ParseDuration(formatted) - Expect(err).ToNot(HaveOccurred()) - Expect(d2).To(Equal(d)) - }, - Entry("1d", "1d"), - Entry("1w", "1w"), - Entry("1d12h", "1d12h"), - Entry("90m", "90m"), - ) -})